Stage 02 — The ring: one enemy, and a spawner that won't stop
You will build: the first enemy — an Area2D that chases the player with fake velocity —
and a spawner that drops new enemies at a random point on a ring around you, forever, up to a
cap.
You'll learn: Path2D as a random-point machine · why 300 enemies should not be 300
bodies · the enemy-cap pattern · design decision 1 from the architecture doc.
Why this exists#
The whole game is "pressure that never stops." The pressure is two scenes and one timer: an enemy that knows where you are, and a ring that knows where the edge of your comfort zone is. Before any of that, though, there is a decision that shapes the entire project, and it's a decision about physics bodies.
Design decision 1 — make the call now, record it (and your reason) in the architecture doc:
- (A) Real bodies. Each enemy is a
CharacterBody2D.move_and_slide()gives them collision for free — they slide around each other, they stop at walls. Cost: every body is registered in the physics server, and the server's work grows with the bodies. - (B) Fake velocity. Each enemy is an
Area2D. You move it yourself:global_position += direction * speed * delta. No bodies, no pushing each other, no cost beyond the math you already wrote.
One-line trade-off: A is honest physics; B is honest performance. The enemies in this game never need to push anything — they need to arrive. B is the recommended path and the steps below assume it, but A is legitimate for a prototype and you can feel its cost in stage 08. Record your choice and reason.
Build it#
A — The enemy, one scene#
New scene Golem: root Area2D named Golem, group enemies. Children:
Golem (Area2D)
├── Skin (Sprite2D) ← another placeholder square, a different colour
├── AnimationPlayer ← one empty animation: "Move"
├── Collider (CollisionShape2D) ← CircleShape2D, radius 6
└── Hurtbox (Area2D) ← for now: just the node, stage 04 gives it a script
└── HurtboxShape (CollisionShape2D) ← CircleShape2D, radius 5
The outer Area2D is the enemy itself — its overlap sensor. The inner Hurtbox is what
your weapon will hit in stage 04. Two areas, one per job, and the layer table in the
architecture doc says which one sees which. For now leave them both on the default layer and
mask.
class_name Enemy
extends Area2D
var move_speed := 35.0
@onready var skin: Sprite2D = $Skin
@onready var animation_player: ______ = $AnimationPlayer # type it: what class is that node?
func _ready() -> void:
animation_player.play("Move")
func _physics_process(delta: float) -> void:
var player := Globals.player
if player == null:
return
var direction := position.direction_to(player.global_position)
global_position += direction * move_speed * delta
skin.flip_h = direction.x < 0.0
Two lines carry the stage:
direction_toreturns a normalized vector — the direction, not the distance. Multiplying byspeed * deltaturns "which way" into "how far this tick". That product is the whole of fake velocity: a velocity you compute and apply yourself, instead of one the physics server hands back.position.direction_to(player.global_position)— one local, one global. If both were global the direction would be the same, but mixing spaces is the classic "enemies orbit instead of chase" bug. Rule: convert to one space, then do the math. Here the enemy's ownposition(local toEntities, which is unrotated, so local ≈ global) against the player'sglobal_positionworks because the parent chain is straight. Draw it if it doesn't click.
You'll notice Globals — you haven't built it. Do it now: a single autoload.
B — Globals, the one autoload this project needs#
Project Settings → Autoload, add a new script as Globals:
extends Node
var player: Player:
get:
var players := get_tree().get_nodes_in_group("player")
return players[0] if players.size() > 0 else null
That's it for now. The group lookup is what makes it work from anywhere: an enemy spawned in
a different part of the tree doesn't need a get_node path through five parents — it asks the
tree by name of the role, and the role is the group. (Groups
are the whole feature; the get property is the sugar.)
In the player's _ready: nothing — the player is already in the group from stage 01. Verify
with print(Globals.player) once after spawn.
C — The ring#
Back in the Game scene, as a child of Player (the ring must follow the walker):
Player
└── SpawnRoot (Path2D)
├── SpawnPath (PathFollow2D)
│ └── SpawnPoint (Marker2D)
└── SpawnTimer (Timer) ← wait_time 0.4, one_shot false, autostart
Draw the Path2D curve: a closed circle, radius 275 around the origin. (275 logical
pixels ≈ one screen-width from the player — far enough to see it coming, close enough to
matter.)
Now the circumference question. A circle's length is 2 * PI * r:
Predict before you run: with r = 275, what number do you expect the path's length to be?
2 * PI * 275 ≈ 1727.9. The PathFollow2D's progress property runs from 0 to the path
length — so a random progress in [0, 1728) is a random point on the ring, and you never
wrote a single sin or cos. That's the entire trick of the Path2D-as-spawner: the
curve is the coordinate system.
Attach a script to Game:
extends Node2D
const MAX_ENEMY_COUNT := 300
const RING_LENGTH := 1728.0
var _enemy_scene: PackedScene = preload("res://entities/enemies/golem/golem.tscn")
@onready var spawn_path: PathFollow2D = $Entities/Player/SpawnRoot/SpawnPath
@onready var entities: Node2D = $Entities
func _on_spawn_timer_timeout() -> void:
if Globals.player == null:
return
var enemies := get_tree().get_nodes_in_group("enemies")
if enemies.size() >= MAX_ENEMY_COUNT:
return
spawn_path.progress = randf_range(0, RING_LENGTH)
var enemy: Enemy = _enemy_scene.instantiate()
enemy.position = spawn_path.global_position
entities.add_child(enemy)
Connect SpawnTimer.timeout to _on_spawn_timer_timeout (the lightning bolt on the timer
node). Walk around and watch: enemies arrive from every direction, always roughly one ring
away, and the crowd tops out at 300 whether you kill them or not — wait, no: you can't kill
them yet, so the cap holds at 300 and the spawner idles. That's the cap doing its job: it
measures alive enemies at spawn time, not "enemies ever spawned", so killing opens the
valve again with zero extra code.
preloadat the top, notloadin the timeout — the scene is a constant of the game; paying the load cost once at startup, not per spawn, is the habit that survives into pooling.instantiate()+add_childis the two-step every runtime spawn takes. The instance exists in memory until you parent it; parenting runs_ready(). (Stage 06 will trip over the ordering of this exact pair and you'll know why it matters.)- The
Globals.player == nullguard: for one frame after a restart the player exists and the group is being rebuilt. A guard that looks paranoid today is the fix for a crash you'd otherwise meet at 2 a.m.
D — Commit#
git add . && git commit -m "stage 02: chasing golem, ring spawner, enemy cap"
Checkpoint — definition of done#
- A golem spawns on the ring, walks straight to you, and stops at you (no dead zone yet — that's stage 03's deliberate bug)
- Spawning continues while you stand still; the ring follows you when you walk
- With 300 alive, the spawner stops without you touching it; print the group size to prove it's measuring, not guessing
-
progressis the only place the ring's geometry appears in code — nosin/cosanywhere - Decision 1 is recorded in the architecture doc with your reason
- Zero warnings; committed
Stretch (no instructions)#
Make the ring's radius breathe: every 30 seconds it grows by 25 pixels. (Hint: the
Path2D's curve is data; you can scale the PathFollow2D's parent, or redraw the curve's
points. There's a one-line version and a silly version — find the one-line version.)
If you get stuck#
- Enemy orbits you in a circle instead of arriving → space mismatch in
direction_to. Both arguments in the same space fixes it; if the enemy's parent rotates, local and global will never agree and the orbit is honest. - Enemy drifts slightly past you and back, forever → fake velocity has no "stop". The distance keeps being measured, the direction keeps flipping. Stage 03 installs a dead zone; for now, live with it and notice it — noticing is the whole point.
- Spawns cluster on one side → your path isn't closed, so
progressruns along an arc, not a circle. Right-click the curve's endpoints in the editor and close it. Globals.playerisnullin the first second → the autoload'sgetruns before the player's_readyadds it to the group. Autoloads start before the main scene; that's why the guard exists.