Free Ebook cover Godot 4 for Beginners: Build a Small 2D Game from Scratch

Godot 4 for Beginners: Build a Small 2D Game from Scratch

New course

10 pages

Scenes, Nodes, and Building Reusable 2D Game Components in Godot 4

Capítulo 2

Estimated reading time: 10 minutes

+ Exercise

Scenes as Prefabs: Your Reusable Building Blocks

In Godot, a Scene is a saved tree of nodes. You can think of it as a “prefab”: a packaged object you can drop into other scenes, duplicate, and even extend into variants. This is the core workflow for building games in a modular way.

A Node is a single component in that tree. Nodes compose behavior by stacking responsibilities: one node might handle movement, another collisions, another visuals, another sound. A scene becomes reusable when it has a clear root node, a predictable set of children, and a script that exposes a small, stable API (properties and methods) for other scenes to use.

Why root node choice matters

  • Node2D: best for “things that have a position/rotation/scale” but don’t need built-in physics movement. Great for containers (like a Level) or simple visual-only objects.
  • CharacterBody2D: best for player/enemy characters that move and collide using kinematic-style movement. It provides velocity and move_and_slide().
  • Area2D: best for triggers and overlaps (pickups, hitboxes, detection zones). It detects bodies/areas entering/exiting via signals.

Reusable Scene 1: Player

Goal

Create a Player.tscn scene that can be instanced into any level, moves with input, and can be detected by collectibles and enemies via groups.

Root node choice

Use CharacterBody2D as the root because the player needs collision-aware movement and a stable movement API.

Suggested node tree

Player (CharacterBody2D)  [Player.gd]  (add to group: "player"); (optional group: "damageable")
├─ Sprite2D
├─ CollisionShape2D
└─ (optional) AnimationPlayer

Step-by-step

  1. Create a new scene, add CharacterBody2D as root, name it Player, save as Player.tscn.
  2. Add Sprite2D for visuals.
  3. Add CollisionShape2D and assign a shape (e.g., CapsuleShape2D or RectangleShape2D).
  4. In the root node, open the Node dock → Groups and add player. This makes it easy for other objects to recognize the player without hard references.
  5. Attach a script Player.gd to the root.

Player.gd (simple movement + small API)

extends CharacterBody2D

@export var speed: float = 220.0

func _physics_process(delta: float) -> void:
	var dir := Vector2(
		Input.get_action_strength("ui_right") - Input.get_action_strength("ui_left"),
		Input.get_action_strength("ui_down") - Input.get_action_strength("ui_up")
	)
	if dir.length() > 1.0:
		dir = dir.normalized()

	velocity = dir * speed
	move_and_slide()

# Small, reusable API other scenes can call
func knockback(from_position: Vector2, force: float = 300.0) -> void:
	var away := (global_position - from_position).normalized()
	velocity = away * force

Keep the player script focused: movement and a couple of methods (like knockback) that other scenes can call without needing to know internal details.

Continue in our app.

You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.

Or continue reading below...
Download App

Download the app

Reusable Scene 2: Enemy (Base)

Goal

Create an EnemyBase.tscn that provides shared enemy functionality (movement, health hooks, group membership). Then create variants via scene inheritance.

Root node choice

Use CharacterBody2D as the root because enemies typically move and collide like the player.

Suggested node tree

EnemyBase (CharacterBody2D)  [EnemyBase.gd]  (add to group: "enemies")
├─ Sprite2D
├─ CollisionShape2D
└─ Detection (Area2D)
   └─ CollisionShape2D

The Detection Area2D is a reusable pattern: it lets the enemy “notice” the player without mixing detection logic into the main collision shape.

Step-by-step

  1. Create a new scene with CharacterBody2D root named EnemyBase, save as EnemyBase.tscn.
  2. Add Sprite2D and CollisionShape2D for the enemy body.
  3. Add a child Area2D named Detection with its own CollisionShape2D (e.g., a circle) to represent detection range.
  4. Add the root to group enemies.
  5. Connect Detection.body_entered to the root script (or connect via code).

EnemyBase.gd (shared behavior)

extends CharacterBody2D

@export var move_speed: float = 120.0
@export var contact_damage: int = 1

var target: Node2D = null

func _ready() -> void:
	add_to_group("enemies")

func _physics_process(delta: float) -> void:
	if target and is_instance_valid(target):
		var dir := (target.global_position - global_position).normalized()
		velocity = dir * move_speed
		move_and_slide()
	else:
		velocity = Vector2.ZERO

func _on_detection_body_entered(body: Node) -> void:
	if body.is_in_group("player"):
		target = body as Node2D

func set_target(new_target: Node2D) -> void:
	target = new_target

This base script is intentionally generic. Variants can override movement style, stats, or reactions while keeping the detection and targeting pattern consistent.

Scene Inheritance: Two Enemy Types Without Duplicating Scripts

Scene inheritance lets you create a new scene that “extends” another scene. The child scene keeps the parent’s node structure and scripts, but can override exported values, replace nodes, add new nodes, or attach an additional script that extends the base script.

Create Enemy Type A: Chaser

  1. Right-click EnemyBase.tscn in the FileSystem → choose New Inherited Scene.
  2. Save as EnemyChaser.tscn.
  3. In the Inspector, tweak exported values on the root: set move_speed higher, maybe adjust detection shape size.
  4. Optionally change the sprite/texture to visually distinguish it.

This variant can use the same EnemyBase.gd without any new script.

Create Enemy Type B: Patroller (extends the base script)

  1. Create another inherited scene from EnemyBase.tscn, save as EnemyPatroller.tscn.
  2. Add a new script to the root that extends EnemyBase.gd. This keeps shared code in one place.
extends "res://EnemyBase.gd"

@export var patrol_distance: float = 120.0
@export var patrol_speed: float = 90.0

var start_x: float
var direction: float = 1.0

func _ready() -> void:
	super._ready()
	start_x = global_position.x

func _physics_process(delta: float) -> void:
	# If we have a target, use base chasing behavior
	if target and is_instance_valid(target):
		super._physics_process(delta)
		return

	# Otherwise patrol left/right
	if abs(global_position.x - start_x) >= patrol_distance:
		direction *= -1.0

	velocity = Vector2(direction * patrol_speed, 0)
	move_and_slide()

Now you have two enemy types that share detection and targeting, but differ in movement. This keeps scripts maintainable: common logic stays in EnemyBase.gd, and only differences live in the derived script.

Reusable Scene 3: Collectible

Goal

Create a Collectible.tscn that detects the player, awards value, and removes itself. This should be easy to place many times in a level.

Root node choice

Use Area2D as the root because a collectible is primarily an overlap trigger, not a physics-moving character.

Suggested node tree

Collectible (Area2D)  [Collectible.gd] (add to group: "collectibles")
├─ Sprite2D
└─ CollisionShape2D

Step-by-step

  1. Create a new scene with Area2D root named Collectible, save as Collectible.tscn.
  2. Add Sprite2D and CollisionShape2D (e.g., CircleShape2D).
  3. In the root, enable monitoring (default) and ensure the collision layers/masks allow overlap with the player.
  4. Add the root to group collectibles.
  5. Connect the body_entered signal to the script.

Collectible.gd

extends Area2D

@export var value: int = 1

signal collected(value: int)

func _ready() -> void:
	add_to_group("collectibles")

func _on_body_entered(body: Node) -> void:
	if body.is_in_group("player"):
		collected.emit(value)
		queue_free()

Using a signal keeps the collectible reusable: the Level (or UI) can listen for collected and update score without the collectible needing to know about the score system.

Reusable Scene 4: Level (Composition and Instancing)

Goal

Create a Level.tscn that instances Player, Enemies, and Collectibles, and uses groups to find and manage them. The Level acts as a composition root: it wires objects together.

Root node choice

Use Node2D as the root because a level is primarily an organizer/container for many 2D nodes. It may not need physics behavior itself.

Suggested node tree

Level (Node2D) [Level.gd]
├─ TileMap (optional)
├─ PlayerSpawn (Marker2D)
├─ Enemies (Node2D)
├─ Collectibles (Node2D)
└─ UI (CanvasLayer) (optional)

Using container nodes like Enemies and Collectibles keeps the scene tidy and makes it easy to spawn and iterate over related objects.

Instancing scenes in the editor

  1. Open Level.tscn.
  2. Drag Player.tscn from the FileSystem into the scene tree (or use Instance Child Scene).
  3. Drag EnemyChaser.tscn, EnemyPatroller.tscn, and Collectible.tscn into their respective container nodes.
  4. Position them in the 2D view.

This is the fastest workflow for hand-placed content.

Instancing scenes via code (spawning)

For procedural placement or respawning, load a PackedScene and instantiate it.

extends Node2D

@export var player_scene: PackedScene
@export var chaser_scene: PackedScene
@export var patroller_scene: PackedScene
@export var collectible_scene: PackedScene

@onready var player_spawn: Marker2D = $PlayerSpawn
@onready var enemies_root: Node2D = $Enemies
@onready var collectibles_root: Node2D = $Collectibles

var player: Node2D

func _ready() -> void:
	spawn_player()
	spawn_demo_enemies()
	spawn_demo_collectibles()

func spawn_player() -> void:
	player = player_scene.instantiate() as Node2D
	add_child(player)
	player.global_position = player_spawn.global_position

func spawn_demo_enemies() -> void:
	var e1 := chaser_scene.instantiate() as Node2D
	enemies_root.add_child(e1)
	e1.global_position = player_spawn.global_position + Vector2(200, 0)

	var e2 := patroller_scene.instantiate() as Node2D
	enemies_root.add_child(e2)
	e2.global_position = player_spawn.global_position + Vector2(-200, 0)

func spawn_demo_collectibles() -> void:
	for i in 5:
		var c := collectible_scene.instantiate() as Area2D
		collectibles_root.add_child(c)
		c.global_position = player_spawn.global_position + Vector2(i * 48, -80)
		c.collected.connect(_on_collectible_collected)

func _on_collectible_collected(value: int) -> void:
	# Hook this into your score/ UI logic
	print("Collected: ", value)

Groups for Categorization and Loose Coupling

Groups let you label nodes (e.g., player, enemies, collectibles) so other systems can find them without hard-coded paths. This is especially useful when you instance scenes dynamically or rearrange the scene tree.

Common patterns

  • Detection by group: the collectible checks body.is_in_group("player") rather than checking a specific node name.
  • Batch operations: the level can find all enemies to pause them, reset them, or apply difficulty scaling.
func freeze_all_enemies() -> void:
	for e in get_tree().get_nodes_in_group("enemies"):
		if e is CharacterBody2D:
			(e as CharacterBody2D).velocity = Vector2.ZERO

When to prefer groups over node paths

NeedPreferWhy
Find “the player” regardless of where it is in the treeGroup playerWorks with instancing and refactors
Access a specific child node you own (like $Sprite2D)Node pathIt’s internal structure, stable within the scene
Apply an effect to many objects (all enemies)Group enemiesBatch operations are simple

Keeping Scripts Maintainable with Reusable APIs

Expose configuration via exported variables

Use @export for values you want to tweak per instance or per inherited scene (speed, detection radius, collectible value). This reduces the need for multiple scripts that only differ by constants.

Keep scene-internal references local

Inside a scene, it’s fine to use @onready var sprite = $Sprite2D. Outside the scene, prefer signals, groups, or small public methods (like set_target()).

Use scene inheritance for variants, not copy/paste

Copying an enemy scene to make a new type often leads to diverging scripts and bugs. Inheritance keeps the shared structure and logic centralized, while allowing each variant to override only what’s different (stats, visuals, or a small derived script).

Now answer the exercise about the content:

You want to make a new enemy type that keeps the same detection/targeting setup as an existing base enemy but changes only movement behavior without duplicating scripts. What is the best approach?

You are right! Congratulations, now go to the next page

You missed! Try again.

Scene inheritance preserves the base scene’s nodes and shared logic (like detection and targeting). A derived scene can tweak exported values or use a script that extends the base script to override only what changes, such as movement.

Next chapter

GDScript Basics Applied: Variables, Functions, Input, and State

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.