doc 5 of 11

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:

  1. not binds looser than <. The line is parsed as if not (dist_sq < 15). The negation applies to the comparison, not to position. (This is the bug class the GDScript docs call out under operator precedence: not a < b is not (a < b).)
  2. distance_squared_to returns squared pixels. The comparison dist_sq < 15 is true when the real distance is under sqrt(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_speed from 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 < 15 was 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_h compares the wrong pair of positions (enemy vs player, or player vs enemy — the sign flips). One of the two positions in the comparison is in the wrong space.
  • The crowd is more metronomic than before the variance → randf_range called with base * 0.75, base * 1.25 around 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.