Skip to content

M4.2 — Detection & the Enemy FSM

What you'll learn

  • Detect the player with a detection Area2D versus a distance check.
  • Run an Idle → Chase → Attack FSM on the M2.4 scaffolding, sharing one target reference.
  • 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 StateMachine the 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 child Area2D with a large circular shape on a player mask. 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 Area2D stores the entered body as target (a Node2D or null) on the enemy, set on body_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 reads get_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 to Chase.
  • Chase — steer toward the target each physics frame. When within attack range, transition to Attack. If the target is lost (left detection, or beyond a de-aggro distance), transition back to Idle.
  • Attack — stop, play the attack animation, enable the enemy's attack hitbox during active frames (the M3.3 pattern), then return to Chase (or Idle if the target is gone).

Example

Idle: wait for a target, then chase. The enemy stores target (set by the detection area):

# res://scripts/states/enemy_idle.gd
extends State

@onready var enemy: CharacterBody2D = owner

func enter() -> void:
    enemy.velocity = Vector2.ZERO
    enemy.sprite.play("idle")

func physics_update(_delta: float) -> void:
    if enemy.target != null:
        transitioned.emit(&"Chase")
    enemy.move_and_slide()

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

  1. Open enemy.tscn. Add a child Area2D named Detection with a large circular CollisionShape2D. Set its mask to player (layer 2) so it detects the player body; it needs no layer of its own.
  2. In enemy.gd, add var target: Node2D = null and the _on_detection_body_entered/exited handlers; connect them to Detection's signals (in _ready or via the editor).
  3. Tag the player: in player.gd's _ready, add_to_group("player"). (Used by detection's group check and later systems.)
  4. 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, play attack, enable the enemy's attack Hitbox (on enemy_hitbox/7) during the swing, then emit &"Chase" on finish.
  5. Add the enemy's attack Hitbox: a child of enemy.tscn (under an AttackPivot if you want facing), on layer enemy_hitbox (7), with a damage value, shape disabled by default.
  6. Under the enemy's StateMachine, add Nodes Idle, Chase, Attack with the three scripts; set the machine's Initial State to Idle.
  7. Press F5. Walk the player toward the skeleton: at the detection radius it enters Chase and comes at you; within attack_range it 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 to Idle.

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.