doc 4 of 11

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_to returns a normalized vector — the direction, not the distance. Multiplying by speed * delta turns "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 own position (local to Entities, which is unrotated, so local ≈ global) against the player's global_position works 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.

  • preload at the top, not load in 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_child is 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 == null guard: 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
  • progress is the only place the ring's geometry appears in code — no sin/cos anywhere
  • 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 progress runs along an arc, not a circle. Right-click the curve's endpoints in the editor and close it.
  • Globals.player is null in the first second → the autoload's get runs before the player's _ready adds it to the group. Autoloads start before the main scene; that's why the guard exists.