Part 04 — Five enemies, one fight
You will build: A coordinator node that holds a fixed number of attack tokens for one room of enemies. Token holders commit and strike; everyone without a token takes an angular slot around the target and applies pressure. Tokens release on death and expire on a timer, so the pool can never starve.
Everything so far has been about one enemy. It has honest senses, a memory that outlives what it can see, and a reaction delay the player can read. Put five of them in a room and all of that stops mattering.
Five correct enemies#
Do this before reading further. Duplicate the enemy scene from Part 03 four times, drop all five into one arena with the player, and fight them.
A minimal arena:
Arena (Node2D)
├── Ground (TileMapLayer)
├── Player (CharacterBody2D)
└── Enemies (Node2D)
├── Enemy
├── Enemy2
├── Enemy3
├── Enemy4
└── Enemy5
Spread them out. Walk in. Watch what happens.
All five perception checks pass within about the same second, because they are all looking at the same player in the same lit room. All five brains reach the same conclusion, because they run the same code against the same world. All five path toward the player's position, because that is the only interesting position in the arena. Within two seconds you are standing inside a knot of five bodies that overlap each other, and every wind-up animation is playing at once.
You die, and you cannot say what killed you.
Now try to debug it. Open the enemy script. Every line is defensible. The vision cone is correct. The memory decay is correct. The reaction delay is correct and tuned. There is no bug in the file, and there never was — the failure is a property of five copies running at once, and no amount of reading one copy will surface it.
That is the shape of the problem for the rest of this part. The missing piece is not inside the enemy. It is a layer above the enemy, and nothing you have written so far lives there.
Two fixes that do not work#
Both of these are the first thing everyone tries. Understanding why they fail is most of the design.
Randomized attack delays. Give each enemy a random wind-up so they stop landing on the same frame:
_wind_up_left = randf_range(0.4, 1.2)
Play it. For about thirty seconds it feels better — the strikes stagger, the room breathes. Then two enemies draw 0.51 and 0.55 and hit together anyway, and with five independent draws per cycle that happens constantly. Random values do not repel each other; they collide at exactly the rate probability says they should.
The deeper problem is that even when the spacing works, it teaches the player nothing. A gap of 0.4 seconds this cycle and 1.1 the next is noise. Part 03 argued that a dangerous enemy has to be legible — the player must be able to learn its timing and beat it. Noise cannot be learned. You have traded a readable disaster for an unreadable one.
A leader who grants permission. Elect one enemy as the leader. Everyone else asks it before attacking. This works, right up to the moment the player kills the leader, which the player will do first because the leader is the one doing things. Now four enemies stand in a ring asking a freed node for permission and receiving nothing. The failure mode of a leader is the whole group locking up, and the player triggered it by playing well.
You can patch it — elect a new leader on death, handle the frame where there is no leader, handle the leader being stunned. Every patch is a special case for a node that should never have been special. The authority does not belong to a participant in the fight.
The attack token#
Put the authority in a thing that cannot die.
A coordinator holds a small pool of permits. Two, for a room of five. Holding a permit is a precondition for entering the attack state: an enemy that wants to attack asks, and if the pool is empty it does not attack. Nothing else about the enemy changes. Perception, memory, reaction delay, the state machine — all of it stays exactly as you wrote it.
What you get for that is a number. max_attackers is now the difficulty dial for the entire encounter, and it is a dial the player can feel. Two attackers and three circlers is a fight with a foreground and a background: the player can see who is committed, deal with them, and track the rest peripherally. Five attackers is the blob from the opening — not harder in any interesting sense, only less legible.
This is the same argument as part 03, one level up. There, fairness came from making a single enemy's intent visible before it landed. Here it comes from limiting how many intents the player has to track at once. A difficulty dial that changes how much information the player must process is a different animal from one that changes damage numbers, and it is usually the better one to reach for.
The coordinator#
Create combat_coordinator.gd:
class_name CombatCoordinator
extends Node
## Group-level authority for one arena: who is allowed to attack right now,
## and where everyone else stands while they wait.
signal contact_reported(reporter: Node2D, last_known: Vector2, seen_at: float)
signal token_expired(holder: Node2D)
@export var max_attackers: int = 2
@export var token_lifetime: float = 2.5
@export var ring_radius: float = 96.0
var _members: Array[Node2D] = []
# Enemy node -> seconds left on that enemy's token.
var _tokens: Dictionary = {}
func register(enemy: Node2D) -> void:
if _members.has(enemy):
return
_members.append(enemy)
func unregister(enemy: Node2D) -> void:
_members.erase(enemy)
_tokens.erase(enemy)
func request_token(enemy: Node2D) -> bool:
if _tokens.has(enemy):
return true
if _tokens.size() >= max_attackers:
return false
_tokens[enemy] = token_lifetime
return true
func release_token(enemy: Node2D) -> void:
_tokens.erase(enemy)
func has_token(enemy: Node2D) -> bool:
return _tokens.has(enemy)
func attacker_count() -> int:
return _tokens.size()
func slot_direction(enemy: Node2D) -> Vector2:
var index: int = _members.find(enemy)
if index == -1 or _members.is_empty():
return Vector2.RIGHT
return Vector2.RIGHT.rotated(TAU * float(index) / float(_members.size()))
func report_contact(reporter: Node2D, last_known: Vector2, seen_at: float) -> void:
contact_reported.emit(reporter, last_known, seen_at)
func _physics_process(delta: float) -> void:
if _tokens.is_empty():
return
# Untyped on purpose: a key can be a freed instance, and a freed instance
# does not belong in an Array[Node2D].
var expired: Array = []
for key in _tokens.keys():
if is_instance_valid(key):
_tokens[key] -= delta
if _tokens[key] > 0.0:
continue
expired.append(key)
for key in expired:
_tokens.erase(key)
if is_instance_valid(key):
token_expired.emit(key)
Three things in there are load-bearing and quiet enough to miss.
release_token erases a key that may not exist, and Dictionary.erase on a missing key is a no-op. That is deliberate. Releasing a token you do not hold has to be harmless, because you are about to call it from three different places and you do not want to reason about which one ran first.
The expiry sweep collects first and erases second. Modifying a container while iterating it is the kind of bug that works in testing and fails in the one fight that matters, and the two-pass version costs you four lines.
token_expired is emitted after the erase, not before. By the time an enemy's handler runs, the pool already reflects reality, so if that handler immediately requests a new token it can actually get one.
Wiring the enemy to it#
Part 03 left you with an enemy that has, under whatever names you gave them, a state enum, a _last_known_position and the timestamp it was recorded at, a _can_see_target flag, and a _set_state function. The snippets below use those names. If yours differ, rename as you type — the shape is what matters, not the spelling.
Add a CIRCLE state to the enum. Then, at the top of the enemy script:
@export var coordinator: CombatCoordinator
@export var hearing_radius: float = 220.0
@export var pressure_amplitude: float = 18.0
@export var pressure_speed: float = 1.7
var _pressure_phase: float = 0.0
var _token_retry_left: float = 0.0
Exporting the coordinator as a node reference means you drag it in per instance in the arena scene. That is one extra step per enemy and it buys you the ability to run two coordinators with different token counts in the same level.
Registration and teardown:
func _ready() -> void:
# ... whatever part 03 already does here ...
_pressure_phase = randf() * TAU
if coordinator == null:
push_error("%s has no coordinator; it will never attack." % name)
return
coordinator.register(self)
coordinator.contact_reported.connect(_on_contact_reported)
coordinator.token_expired.connect(_on_token_expired)
func _exit_tree() -> void:
if coordinator != null:
coordinator.unregister(self)
The push_error matters more than it looks. An enemy with no coordinator will never hold a token and therefore never attack, which on screen looks identical to a dozen other bugs. Make the real cause say its own name in the console.
Now the gate itself. Wherever part 03's brain decides to enter ATTACK, it now decides to enter CIRCLE first, and CIRCLE is what asks:
func _release_token() -> void:
if coordinator != null:
coordinator.release_token(self)
func _set_state(next: State) -> void:
if next == _state:
return
if _state == State.ATTACK:
_release_token()
_state = next
_state_time = 0.0
That one if in _set_state is the whole release path for the normal case. Every route out of the attack state — the strike landing, the target vanishing, a stagger, a state you have not written yet — goes through _set_state, so every route returns the token. If your machine has no exit hook, this is the moment to add one; /systems/state-machine/03-state-nodes covers the version where each state owns its own enter and exit.
What the others do while they wait#
An enemy that is denied a token and stands still is worse than the blob. The blob at least looked like a fight. Four statues and one attacker looks like the AI crashed.
Denied enemies get a slot on a ring around the target and keep moving:
func _tick_circle(delta: float) -> void:
_pressure_phase += pressure_speed * delta
_token_retry_left -= delta
var radius: float = coordinator.ring_radius + sin(_pressure_phase) * pressure_amplitude
var slot: Vector2 = _last_known_position + coordinator.slot_direction(self) * radius
var desired: Vector2 = Vector2.ZERO
if global_position.distance_to(slot) > 4.0:
desired = global_position.direction_to(slot) * move_speed
velocity = velocity.move_toward(desired, acceleration * delta)
move_and_slide()
# Facing comes from the target, not from velocity: while circling, velocity
# points sideways, and a facing built from it would swing the vision cone
# away from the player it is supposed to be watching.
_face(global_position.direction_to(_last_known_position))
if not _can_see_target:
_set_state(State.SEARCH)
return
if _token_retry_left > 0.0:
return
if coordinator.request_token(self):
_set_state(State.ATTACK)
else:
_token_retry_left = 0.4
Call it from the CIRCLE arm of the enemy's _physics_process match.
The retry cooldown is there so a denied enemy asks a few times a second instead of every physics frame. With five enemies the cost of asking is nothing; the reason to space it out is that a rejected enemy which re-asks instantly will grab the token the instant a holder releases it, always the same one, because it happens to run first in the tree. A short retry window lets the grab order vary.
sin(_pressure_phase) with a phase seeded randomly per enemy is what turns a static ring into pressure. Each circler drifts in and out by pressure_amplitude on its own rhythm, so the ring pulses instead of sitting there. Cheap, and it reads from across the screen.
The ring is what makes the token system legible. Without it, the player sees two enemies attacking and three doing something unclear. With it, the player sees two committed bodies and three circling ones, and the difference between those two things is visible in the silhouette. The pool limits the danger; the ring communicates the limit.
Two honest costs. Slot indices come from registration order, so an enemy can be handed a slot on the far side of the player and walk all the way around to reach it. And when a member dies the ring re-indexes, so the survivors slide to new angles. Neither is wrong, both are visible, and both have fixes listed under the stretch section.
While debugging, tint the holders:
func _refresh_debug_tint() -> void:
if coordinator == null:
return
_sprite.modulate = Color(1.0, 0.55, 0.55) if coordinator.has_token(self) else Color.WHITE
Two lines, and now the token system is something you can watch instead of something you infer.
Two deadlocks that look like a frozen AI#
Both of these end with a room full of enemies doing nothing, and both are token leaks. A leaked token is never coming back on its own, so the pool shrinks by one permanently, and after two leaks a max_attackers of 2 means nobody ever attacks again.
The dead holder. An enemy takes a token, winds up, and dies mid-swing. If its death path does not go through _set_state, the release hook never runs. _exit_tree will eventually catch it — but only when the node is actually freed, and a decent death has a 1.2 second animation before queue_free. For that second and a bit, a corpse is holding a permit. So release on the death signal as well:
func _on_died() -> void:
_release_token()
Two paths for the same job is not redundancy you should feel bad about. The death signal returns the token at the moment the enemy stops being a threat; _exit_tree is the backstop for every way a node can leave the tree that you did not think of, including scene changes and the editor.
The stuck holder. An enemy takes a token, starts closing in, and snags on a corner of the collision geometry. It is alive, its state machine is running, it is not going to reach you, and it is not going to release. The group is now hostage to a pathing failure.
There is no clean fix at the enemy level, because the enemy does not know it is stuck. The fix is at the pool: token_lifetime counts down and the coordinator takes the permit back whether the holder is finished or not. The holder finds out through the signal:
func _on_token_expired(holder: Node2D) -> void:
if holder != self:
return
if _state == State.ATTACK:
_set_state(State.CIRCLE)
Note that _set_state will call _release_token on the way out, which erases a key the coordinator already erased. Harmless, by construction, which is why release_token was written to tolerate it.
token_lifetime needs to be comfortably longer than a real attack cycle. If a healthy attack takes 1.4 seconds and the lifetime is 1.5, you will occasionally revoke a token from an enemy that was doing fine, and the visible symptom is an enemy that backs out of a swing for no reason. Time your slowest attack, then roughly double it.
Where the coordinator lives#
Two placements, both defensible.
An autoload. One coordinator for the whole game, reachable from any script by name, surviving scene changes. Nothing needs an exported reference and nothing can be left unassigned. The cost is that it is global mutable state: when a token leaks, the answer to "who took it" is every enemy that has ever existed, and the tracing problems that come with that are the same ones laid out in /systems/event-bus/00-overview. It also carries state across rooms, so a bug in one arena follows you into the next.
A node per room. The coordinator is a child of the arena scene and dies with it. The pool is scoped to the fight, max_attackers is a per-encounter property you can set in the inspector, and a boss arena can run 1 while a corridor ambush runs 3. The cost is the wiring: every enemy instance needs a reference, and an unassigned one is a bug you have to make loud yourself, which is what the push_error above is for.
The deciding question is whether token counts differ between encounters. If any fight in your game wants a different number of simultaneous attackers than any other, the per-room node wins immediately and there is nothing further to weigh. If every fight in the game genuinely wants the same number, the autoload saves you real wiring, and you can move to per-room later — the enemy code is identical either way, only the reference changes.
Telling each other, without telling each other everything#
One enemy spots you and the other four keep patrolling, oblivious, in a room the size of a living room. That reads as stupid.
The temptation is to let the spotter hand the player's transform to everyone. Do that and you have rebuilt the omniscient enemy from Part 01 with extra steps: every enemy in the level now tracks a live position it never earned, and the fact that the data arrived through a signal rather than a direct reference changes nothing about what the player experiences.
Propagate the memory instead of the truth. What travels is a last known position and the moment it was recorded. What gates it is distance and delay.
One conversion before any of that works. Part 02 stored freshness as time_since_seen — seconds counting up since the last sighting, where smaller means fresher. That is the right shape for one enemy deciding how stale its own belief is, and the wrong shape the instant two enemies compare notes, because your 1.2 seconds and my 1.2 seconds were measured from different moments and neither of us knows the other's zero. Comparing freshness across a room needs a stamp both enemies read the same way:
_last_seen_time = Time.get_ticks_msec() / 1000.0
Write that wherever part 02 set time_since_seen = 0.0, and derive staleness as Time.get_ticks_msec() / 1000.0 - _last_seen_time wherever part 02 read the counter. Now larger means fresher, the same number means the same instant on every enemy in the arena, and the comparison below is a comparison rather than a guess. Keep the old counter and the guard inverts on you: fresh reports get discarded, stale ones get accepted, and the enemy that was told where you are forgets a moment later.
The spotter reports:
func _on_target_spotted() -> void:
if coordinator != null:
coordinator.report_contact(self, _last_known_position, _last_seen_time)
Every enemy decides for itself whether it heard:
func _on_contact_reported(reporter: Node2D, last_known: Vector2, seen_at: float) -> void:
if reporter == self:
return
if _can_see_target:
return
if global_position.distance_to(reporter.global_position) > hearing_radius:
return
if seen_at <= _last_seen_time:
return
_last_known_position = last_known
_last_seen_time = seen_at
_alert_delay_left = reaction_time
Each of those guards is doing a job. Own eyes beat hearsay, so a spotter ignores the broadcast. The distance check is measured against the reporter, not the player — the shout comes from the enemy who saw you, which is what lets the player kill a lone scout before the shout matters, or outrun the alert by leaving the area it covers. The timestamp check stops old information from overwriting fresher information, which happens constantly once two enemies are both reporting.
The last line routes the alert through the same reaction delay part 03 already built. The alerted enemy does not snap around; it takes its beat, exactly like an enemy that saw you itself. That delay is also what makes the alert visible: the player gets to watch it travel from one enemy to the next instead of watching the whole room turn on one frame.
If a script needs the player and does not already have a reference, ask the tree for the group rather than hardcoding a path:
var player: Node2D = get_tree().get_first_node_in_group("player") as Node2D
get_first_node_in_group is the right call for a singleton. get_nodes_in_group("player")[0] builds and throws away an entire array to answer the same question, and in an enemy's per-frame code that adds up.
Tokens or steering#
There is a version of this part that never mentions tokens. Give every enemy a separation force that pushes it away from its neighbours, a seek force toward the player, and a desire to hold a comfortable distance. Sum them, move. No coordinator, no registration, no expiry, no signals. Less code by a wide margin, and the motion that comes out looks more organic than anything a fixed ring will produce, because it is continuous rather than assigned.
What it does not give you is a guarantee. Steering forces produce a tendency to spread out. In the average frame that is enough. In the frame where the player backs into a corner and the geometry removes the room the separation force needed, all five converge — and that is precisely the failure this part opened with, returning at the worst moment in the fight.
Tokens give you the guarantee and cost you the naturalism. The count is explicit, tunable per encounter, and visible in a debugger; the ring is more regular than a crowd would really be.
The deciding question is whether your design needs a guarantee about simultaneous attackers or only a tendency. A game where the player has one dodge with fixed invulnerability frames needs the guarantee, because two simultaneous attacks from opposite sides is an unwinnable state and "usually does not happen" is not a defence. A game where the player has a shield, a lot of health, and room to run can take the tendency and spend the saved complexity elsewhere. Nothing stops you from running both — steering for the moment-to-moment spacing, tokens for the hard cap on how many enemies are dangerous at once.
Checkpoint — definition of done#
- Five enemies fight you and the tint shows at most two red bodies at any moment, across a full sixty-second fight.
- Kill a token holder mid-wind-up. A circler turns red and commits within about a second.
- Wedge a token holder behind geometry so it can never reach you. Its tint clears within
token_lifetimeand another enemy takes over; the fight does not stall. - The three circlers occupy visibly different angles around you. No two enemies are stacked on the same point.
- Walk to the other side of the arena. The ring re-forms around your new position instead of collapsing into a queue behind you.
- One enemy spots you while another stands beyond its
hearing_radius. The far one stays unaware until it sees you itself. - Watch an alert travel: the enemy that heard the shout takes its reaction beat before turning, not zero frames.
- Set
max_attackersto 5, fight, and confirm you get the opening blob back. Set it to 1 and confirm the fight becomes a duel with an audience. - Print
attacker_count()every second for a two-minute fight with deaths in it. The number returns to 0 when no enemy is attacking, every time. - You can explain: why the token has to expire on a timer even though every enemy releases its own token correctly.
Stretch (no instructions)#
- Assign slots by nearest available angle instead of registration order, so an enemy takes the gap closest to where it already is.
- Rotate the whole ring slowly, or orient slot 0 to the player's facing so the enemy directly behind the player is always the one flanking.
- Let
token_lifetimebe set by the attack itself: a slow overhead swing asks for a longer permit than a quick jab. - Add a second pool with its own count for ranged enemies, so melee and ranged pressure are tuned separately.
- Make the coordinator draw its ring and its holders as a debug overlay you can toggle in game.
If you get stuck#
- All five still attack at once → at least one enemy instance has an unassigned
coordinatorexport. Check the console for thepush_errorline naming it, and check every instance in the arena, not the scene file. - Nobody ever attacks, from the first frame → the token gate is reached before
registerruns, orcoordinatoris null on every instance. Printattacker_count()and_members.size()in_readyorder to see which. - The fight stalls after a minute or two → a token leak. Print
attacker_count()on a key press; if it sits atmax_attackerswith nobody visibly attacking, a holder left the attack state by a route that skips_set_state, or died without emitting its death signal. - An enemy backs out of a swing for no reason →
token_lifetimeis shorter than a full attack cycle, so the coordinator is revoking permits from healthy holders. Time the slowest attack and set the lifetime well above it. - All circlers pile onto the same slot →
slot_directionis returningVector2.RIGHTfor all of them, which meansfindreturned -1, which means those enemies never registered or registered with a different coordinator instance. - Circlers drift away from the player and never come back →
_last_known_positionis stale because the circle state never refreshes perception. The ring is built around memory, so the memory has to keep updating while circling. - Enemies circle with their vision cone pointing sideways → facing is still derived from velocity. While circling, velocity is tangential; face the target directly.
- One enemy spots you and the entire level turns instantly → the hearing check is measuring distance to the player instead of to the reporter, so every enemy near you hears every shout regardless of where the shout came from.
- An enemy forgets you a moment after being alerted → the alert wrote
_last_known_positionbut not_last_seen_time, so the decay treats the new information as ancient and discards it on the next tick. If every alert behaves this way rather than some of them,_last_seen_timeis still part 02's count-uptime_since_seen, and theseen_at <= _last_seen_timeguard is reading it backwards — fresh reports rejected, stale ones let through. - Errors about a freed instance in the coordinator → an enemy was freed without
unregisterrunning. Confirm_exit_treeis on the enemy script itself and not on a child node that outlives it.
Next: Event bus — the wiring underneath a coordinator. The coordinator here is a small, scoped piece of shared authority, and the arguments that make it safe — who is allowed to write, who is allowed to listen, how you trace a message back to its sender — are the same arguments that decide whether a project-wide bus helps you or buries you. That shelf makes them explicit before you build one.