M4.2 — Detection & the Enemy FSM¶
What you'll learn
- Detect the player with a detection
Area2Dversus a distance check. - Run an
Idle → Chase → AttackFSM on the M2.4 scaffolding, sharing onetargetreference. - Treat aggro and de-aggro as explicit, tunable transitions.
How it applies
- Detection defines difficulty and fairness. How far an enemy sees decides whether the player can pick fights or gets swarmed. Too large and the whole room aggros at once; too small and enemies feel oblivious. It is a primary tuning surface, and exposing it per-enemy lets a designer make a sneaky archer and an oblivious brute from the same code.
- The FSM makes AI legible. "Idle until it sees you, chase until in range, attack, then chase
again" is four states and three transitions. As an FSM it reads like a sentence; as nested
ifs it rots into the same flag tangle M2.4 warned about. Readable AI is debuggable AI. - De-aggro is a real mechanic. Whether an enemy chases forever or gives up when the player escapes changes the entire feel of moving through a dungeon (can you outrun fights?). Making it an explicit transition, not a side effect, is what lets you tune it.
- Reuse, again. This is the same
StateMachinethe player uses; the enemy just has different concrete states. The scaffolding pays off a third time.
Concepts¶
Two ways to detect¶
- Detection
Area2D. Give the enemy a childArea2Dwith a large circular shape on aplayermask. When the player body enters, the enemy knows the player is in range; when it exits, the player left. Event-driven (no per-frame work), and the shape can be any geometry (a forward cone for line-of-sight flavor). This is the recommended default. - Distance check. Each physics frame, compare
global_position.distance_to(player.global_position)against a radius. Trivial to write, but it polls every frame and needs a player reference; fine for a handful of enemies, wasteful for a room full.
This book uses a detection Area2D for aggro range, optionally combined with a distance check inside
Chase/Attack for the tighter "am I in attack range?" question (a small distance is cheap once already
chasing).
Sharing the player reference and detection state¶
States need to know where the player is. Rather than each state searching the tree (slow, fragile), the enemy body finds the player once and the detection area updates a flag. Two clean approaches:
- The detection
Area2Dstores the entered body astarget(aNode2Dornull) on the enemy, set onbody_entered/body_exited. - Or the enemy looks up the player via a group: tag the player node with
add_to_group("player")and the enemy readsget_tree().get_first_node_in_group("player")once in_ready. Groups avoid hardcoded paths and survive scene rearrangement.
Combine them: use the group to get the player reference, and the detection area to decide whether the
enemy currently cares (is the player within aggro range). States read enemy.target (set by detection)
to branch.
The enemy states¶
Idle— stand (or patrol). When detection reports a target, transition toChase.Chase— steer toward the target each physics frame. When within attack range, transition toAttack. If the target is lost (left detection, or beyond a de-aggro distance), transition back toIdle.Attack— stop, play the attack animation, enable the enemy's attack hitbox during active frames (the M3.3 pattern), then return toChase(orIdleif the target is gone).
Example
Idle: wait for a target, then chase. The enemy stores target (set by the detection area):
Example
Chase: move toward the target; hand off to Attack when close, back to Idle when the target is
gone. (This uses a straight-line steer; M4.3 swaps in real pathfinding.)
# res://scripts/states/enemy_chase.gd
extends State
@onready var enemy: CharacterBody2D = owner
@export var attack_range: float = 28.0
func physics_update(_delta: float) -> void:
var target: Node2D = enemy.target
if target == null:
transitioned.emit(&"Idle")
return
var to_target := target.global_position - enemy.global_position
if to_target.length() <= attack_range:
transitioned.emit(&"Attack")
return
enemy.velocity = to_target.normalized() * enemy.speed
enemy.sprite.play("run")
enemy.sprite.flip_h = enemy.velocity.x < 0.0
enemy.move_and_slide()
attack_range is an exported per-state knob — a longer-reach enemy sets it higher. The state reads
enemy.target (kept current by the detection area) rather than searching for the player itself.
Example
The detection area keeps target current. On the enemy body:
# in enemy.gd
var target: Node2D = null
func _on_detection_body_entered(body: Node2D) -> void:
if body.is_in_group("player"):
target = body
func _on_detection_body_exited(body: Node2D) -> void:
if body == target:
target = null
Connect these to the detection Area2D's body_entered/body_exited. Now target is non-null
exactly while the player is within aggro range, and every state reads one shared, always-current
value.
Aggro and de-aggro as decisions¶
The detection radius sets aggro (enter Chase). De-aggro — when the enemy gives up — is a separate
decision: the simplest is "lose target when it exits the detection area," but you might prefer a larger
de-aggro radius (so enemies don't instantly forget at the edge) or a timeout (chase for N seconds after
losing sight). Whichever you choose, make it an explicit transition condition in Chase, not an implicit
consequence of the radius — that is what lets you tune "can the player escape fights?"
Walkthrough¶
- Open
enemy.tscn. Add a childArea2DnamedDetectionwith a large circularCollisionShape2D. Set its mask toplayer(layer 2) so it detects the player body; it needs no layer of its own. - In
enemy.gd, addvar target: Node2D = nulland the_on_detection_body_entered/exitedhandlers; connect them toDetection's signals (in_readyor via the editor). - Tag the player: in
player.gd's_ready,add_to_group("player"). (Used by detection's group check and later systems.) - Create
res://scripts/states/enemy_idle.gd,enemy_chase.gd, and (reusing the M3.3 attack shape)enemy_attack.gd. The enemy attack state mirrors the player's: stop, playattack, enable the enemy's attackHitbox(onenemy_hitbox/7) during the swing, then emit&"Chase"on finish. - Add the enemy's attack
Hitbox: a child ofenemy.tscn(under anAttackPivotif you want facing), on layerenemy_hitbox(7), with adamagevalue, shape disabled by default. - Under the enemy's
StateMachine, addNodesIdle,Chase,Attackwith the three scripts; set the machine's Initial State toIdle. - Press
F5. Walk the player toward the skeleton: at the detection radius it entersChaseand comes at you; withinattack_rangeit stops and swings (and, with M3.4 wiring on the player, you take damage, flash, and get knocked back). Walk away past the detection radius and it returns toIdle.
Optional sanity check
Add print("enemy state -> ", name) to each enemy state's enter(). Approach and retreat a few times
and confirm the sequence reads Idle → Chase → Attack → Chase → Idle with exactly one state active at
a time. If you see Chase and Attack thrashing every frame at the boundary, your attack_range and
the enemy's stopping distance are too close — widen the gap or add a small hysteresis. Remove the
prints.
Self-check quiz¶
Q1 — Why prefer a detection Area2D over a per-frame distance check for aggro range?
A. Distance checks can't compute distance in 2D. B. The area is event-driven (it fires on enter/exit, no per-frame work) and supports any shape, while a distance check polls every frame for every enemy. C. The area is more accurate to the pixel. D. Distance checks don't work with groups.
Reveal answer
B. The area updates the target only when the player crosses its boundary, so a room of enemies costs nothing while nothing changes; a distance check runs every physics frame per enemy regardless. The area also allows non-circular detection (cones). A is false. C is the opposite if anything (distance is exact; the area uses a shape). D is false (you can combine them, which this chapter does).
Q2 — Why does the Chase state read enemy.target rather than searching the scene tree for the player each frame?
A. Searching the tree is illegal in physics_update.
B. target is a single shared value kept current by the detection area's enter/exit events, so every
state reads an always-correct reference without repeated, fragile tree searches.
C. The player isn't in the tree.
D. enemy.target is faster to type.
Reveal answer
B. The detection area maintains target as the one source of truth for "who am I after," set
on enter and cleared on exit; states just read it. Searching the tree per frame is slower and
couples each state to the tree's shape. A is false (searching is allowed, just wasteful). C is
false. D is not the reason.
Q3 — Why treat de-aggro (giving up the chase) as an explicit transition condition rather than just 'the player left the detection area'?
A. The detection area can't detect exits. B. Because how and when an enemy gives up (instant at the edge, larger de-aggro radius, or a timeout) changes whether the player can escape fights — a design decision worth controlling, not leaving to the radius. C. Exits don't fire signals. D. De-aggro must always be instant.
Reveal answer
B. Coupling de-aggro to the exact aggro radius makes enemies forget the player the instant they
step over the line, which feels twitchy; controlling it explicitly (timeout, larger forget radius)
is how you tune the dungeon's "can I outrun this?" feel. A and C are false (the area fires
body_exited). D is wrong — instant is one choice among several.
Integration question¶
Q4 — open
The enemy's Idle → Chase → Attack machine is the same StateMachine/State system the player uses,
yet the player's states read Input and the enemy's read enemy.target and positions. Explain what
this says about the M2.4 design, and describe how the enemy's Attack state reuses the M3.3 player
attack pattern despite the two actors sharing no attack code.
Reveal expected answer
It shows the M2.4 FSM was correctly built input-agnostic: the StateMachine pumps the active
State and swaps on a transitioned signal, knowing nothing about where the decision to
transition comes from. The player's states source their transitions from Input; the enemy's
source them from enemy.target (set by the detection area) and distance comparisons — but the
machinery is identical, which is why the same scaffolding runs a human-controlled actor and an AI
one. The enemy's Attack state reuses the M3.3 pattern, not its code: like the player's, it
stops the body, plays a non-looping attack animation, enables an attack Hitbox during the
active frames (here on enemy_hitbox/7 so the player's Hurtbox detects it), and transitions out
on animation_finished (to Chase instead of Idle). The two actors share the shape of
"attack = a committed state that gates a hitbox by animation," parameterized by which layer the
hitbox is on and which state to return to — the reuse is architectural, achieved by both actors
instantiating the same components and following the same state pattern, not by literally sharing
an attack function.