Stage 03 — The crowd: thirty enemies that aren't clones
You will build: an EnemyStats node so each enemy species carries its own numbers, a
speed variance so the crowd stops moving like a metronome, a working dead zone (after you
predict — and meet — the bug the reference project actually shipped), and a frame-skipped
skin flip that teaches you to stop paying per-frame prices for per-second facts.
You'll learn: stats nodes as data homes · operator-precedence bugs you can predict ·
distance_squared_to vs distance_to · presentation work on a slower cadence.
Why this exists#
Stage 02's enemies are identical. Thirty of them chase you at 35 px/s in perfect unison and the wave reads as one enemy with a wide body. Real crowds have texture: some pull ahead, some lag, and the ones right on top of you stop crowding your face. All of that is four small changes — and one of them is a genuine bug that this project shipped, worth meeting on purpose.
Build it#
A — EnemyStats, a species' number home#
New scene-less script EnemyStats (extends Node), instantiated inside Golem.tscn as a
child of Golem, named EnemyStats:
class_name EnemyStats
extends Node
## Base speed for this species. Individuals vary around it — see the controller.
@export var move_speed := 35.0
Now the Golem editor is the species definition: make a Golem variant Brute, set its
stats to 22, and you've designed a slower, (later) tougher enemy — one @export changed, zero
code. This is the first instance of the pattern the data-driven shelf
treats in full: code for the shape, data for the values.
The enemy controller adopts it:
class_name Enemy
extends Area2D
var move_speed := 35.0
var knockback: Vector2 = Vector2.ZERO
@onready var skin: Sprite2D = $Skin
@onready var enemy_stats: EnemyStats = $EnemyStats
@onready var animation_player: AnimationPlayer = $AnimationPlayer
func _ready() -> void:
var base := enemy_stats.move_speed
move_speed = randf_range(base * 0.75, base)
animation_player.play("Move")
Why vary at all? Identical speeds sync the crowd into a breathing wall — all enemies
arrive, all stop, all resume, on the same beat. A uniform ±25% band (here, downward-only:
randf_range(0.75 * base, base)) breaks the rhythm with no species losing its identity —
every golem is still recognisably a golem. The band is a design number, not a physics fact:
widen it and the crowd gets chaotic, narrow it and the metronome returns.
B — The dead zone, the honest version#
The orbiting from stage 02 needs a stop condition: enemies shouldn't be inside your hitbox before combat even exists. Write the first attempt — the one this project actually shipped:
func _physics_process(delta: float) -> void:
var player := Globals.player
if player == null:
return
var direction := position.direction_to(player.global_position)
var fake_velocity := direction * move_speed * delta
knockback = knockback.move_toward(Vector2.ZERO, 60.0 * delta)
fake_velocity += knockback
if not position.distance_squared_to(player.global_position) < 15:
global_position += fake_velocity
Predict before you run: what dead zone does that line create? Be specific — in pixels, and say which pixels.
Two independent errors, and they compound:
notbinds looser than<. The line is parsed asif not (dist_sq < 15). The negation applies to the comparison, not toposition. (This is the bug class the GDScript docs call out under operator precedence:not a < bisnot (a < b).)distance_squared_toreturns squared pixels. The comparisondist_sq < 15is true when the real distance is undersqrt(15) ≈ 3.9. So the "15 pixel dead zone" is a 3.9 pixel dead zone — the enemy stops orbiting at ~4 px, which is inside your 12×12 collider.
Running it confirms the prediction: enemies stop almost on top of you, and for a few frames the sprite is inside your sprite's rectangle. The fix is two lines — convert to the space you actually mean:
var distance := position.distance_to(player.global_position)
if distance > 15.0:
global_position += fake_velocity
distance_to costs one square root distance_squared_to avoids — at one call per enemy per
frame that is nothing (stage 08 measures it and confirms). The lesson isn't "never use
distance_to"; it's don't compare a squared unit against a unit you read off the screen,
and don't let not dress up a comparison.
C — Knockback, in one line of physics#
The knockback vector above is a half-finished feature on purpose: it's the damping half.
Every tick it moves toward zero at 60 px/s² — an impulse that bleeds out — and nothing has
pushed it yet. That's the standard shape for any "hit and get shoved" system: an impulse
added to velocity, damped each tick — the same move_toward decay as your player's
friction. The game feel shelf's knockback part
builds the full treatment; you just installed the machinery.
Now give it a pusher. Connect the golem's outer area_entered to:
func _on_area_entered(other: Area2D) -> void:
if other is Enemy:
var nudge := position.direction_to(other.global_position) * 8.0
other.knockback += nudge
Two enemies landing on the same pixel at spawn shove apart over a few frames instead of
remaining glued. Be honest about what this is not: at 300 enemies it does nothing about
the general pile-up — the real answer is a per-frame separation force, which is the
steering shelf's first rule with a weight attached.
This nudge exists for spawn coincidences only. (The reference project shipped an even smaller
version of it — 0.25 — which is a correction you can feel in one frame: too small to read
as a shove, too big to ignore. 8.0 is the first value that looks like contact.)
D — The flip on a slower cadence#
The skin flip is a presentation fact: it changes when you cross the enemy's vertical, which for a chasing enemy is a few times a minute. Computing it 60 times a second is paying full price for a per-minute fact. The project's answer — a counter that gates the check:
var _frame := 0
const FLIP_CHECK_EVERY := 6
# inside _physics_process, after the movement:
_frame += 1
if _frame % FLIP_CHECK_EVERY == 0:
skin.flip_h = position.x > player.global_position.x
Every 6th physics frame ≈ 10 checks/second — still indistinguishable to a human, one sixth of the work. This is the frame-skip habit: classify each line of per-frame work by how often its answer can honestly change, and pay only that often. Stage 08 turns the habit into a budget; here it's just a counter and a modulo.
E — Commit#
git add . && git commit -m "stage 03: enemy stats, speed variance, dead zone, frame-skipped flip"
Checkpoint — definition of done#
- Thirty golems alive, and printing
move_speedfrom five of them gives five different numbers, all within[0.75·base, base] - Enemies stop ~15 px from the player — measure it once with a debug print of
distance, not by eye - A second golem spawning on top of one gets shoved within a few frames
- The flip still updates — walk a golem across your vertical and watch it
- You can explain, out loud or in a comment, what
not dist_sq < 15was doing before the fix — both errors, not just one - Zero warnings; committed
Stretch (no instructions)#
A Brute species: a Golem variant at 22 base speed with a bigger collider and skin. If you
have to write any code to make it — beyond the variant — find where the species assumption
hid, and move it to EnemyStats.
If you get stuck#
- Enemies stop at 3–4 px (inside your sprite) → you're still comparing a squared distance against a linear pixel count. The fix is a space conversion, not a bigger number.
- Knockback shoves enemies through the dead zone and they resume chasing from inside → correct behaviour! The dead zone only gates the chase term; the impulse is independent. If that reads as a bug, that's the layer between "movement" and "force" — name it and move on.
- All flips face left →
flip_hcompares the wrong pair of positions (enemy vs player, or player vs enemy — the sign flips). One of the twopositions in the comparison is in the wrong space. - The crowd is more metronomic than before the variance →
randf_rangecalled withbase * 0.75, base * 1.25around a shared seed, or the variance multiplied after the per-tick math instead of stored once in_ready. Store it once; the speed is a fact about the enemy, not about the frame.