DEV Community

saltmire
saltmire

Posted on Originally published at saltmire.github.io

What I learned building an enemy state machine in Godot 4

I wrote "just use a match statement, it's fine" three times before I stopped saying it. It
is fine, right up until an enemy needs a fourth state and two of the transitions start
depending on each other. Here is what actually cost time building enemy AI for a
wave-based game, in the order it bit me.

Lesson 1: the match statement is fine until state 4

A two-state enemy — chase, attack — is genuinely not worth a framework:

func _physics_process(delta: float) -> void:
    match state:
        State.CHASE:
            velocity = (player.global_position - global_position).normalized() * speed
            if global_position.distance_to(player.global_position) < attack_range:
                state = State.ATTACK
        State.ATTACK:
            attack_timer -= delta
            if attack_timer <= 0.0:
                do_attack()
                state = State.CHASE
Enter fullscreen mode Exit fullscreen mode

The moment a third and fourth state show up — hurt, dead, stagger, windup — the match
block stops being one enemy's logic and becomes a grid of every state times every other
state it might transition to. That grid is where the bugs live, not in any single state.

Lesson 2: the bug is never inside a state, it's in the transition

Every state-machine bug I actually spent time on was the same shape: state A left some
flag or timer set that state C didn't know to check. An enemy stuck mid-attack-animation
forever, still receiving hits, was not a bug in the attack state — it was the hurt state
interrupting attack without cleaning up attack_timer or resetting the animation.

The fix that made these bugs findable is giving every state an explicit enter and
exit, and never mutating another state's data directly:

func change_state(new_state: State) -> void:
    if new_state == state:
        return
    _exit_state(state)
    state = new_state
    _enter_state(new_state)

func _exit_state(s: State) -> void:
    match s:
        State.ATTACK:
            attack_timer = 0.0
            sprite.stop()

func _enter_state(s: State) -> void:
    match s:
        State.HURT:
            velocity = Vector2.ZERO
            hurt_timer = HURT_DURATION
            sprite.play("hurt")
Enter fullscreen mode Exit fullscreen mode

One entry point for every transition means you can put a single print in
change_state and see the entire life of an enemy in the log, instead of guessing which
of six scattered state = X lines fired.

Lesson 3: a Node-per-state is not overkill once you have more than one enemy type

I resisted the State-as-a-Node pattern for a long time — it felt like ceremony for
something a match statement already did. It earns its cost the moment you have more than
one enemy type sharing behavior: a ranged enemy and a melee enemy both need chase and
hurt, but attack is completely different.

class_name EnemyState
extends Node

func enter(_enemy: Node) -> void: pass
func exit(_enemy: Node) -> void: pass
func physics_update(_enemy: Node, _delta: float) -> void: pass

class_name ChaseState
extends EnemyState

func physics_update(enemy: Node, delta: float) -> void:
    enemy.velocity = enemy.direction_to_player() * enemy.speed
    if enemy.in_attack_range():
        enemy.state_machine.change_to("attack")
Enter fullscreen mode Exit fullscreen mode

Now attack is swappable per enemy scene without touching chase or hurt at all. For
a single enemy type, this is genuinely more code than a match statement for no benefit —
the crossover point is real, not just taste.

Lesson 4: "dead" is a state too, and it needs to block every other transition

The bug that took longest to track down: an enemy could take lethal damage, enter
hurt, and the hurt-state's timer would flip it back to chase — bringing a corpse back
to life for one frame before queue_free() caught up. dead was handled as an
if health <= 0: queue_free() check scattered in three places instead of being a real
state in the machine.

func take_damage(amount: int) -> void:
    health -= amount
    if health <= 0:
        change_state(State.DEAD)   # one path in, nothing else can override it
        return
    change_state(State.HURT)
Enter fullscreen mode Exit fullscreen mode

Once dead was a state like any other — with its own enter() that disables the
hitbox, stops physics, and plays the death animation before freeing — the flicker-back-
to-life bug had nowhere left to come from.

What I'd do differently starting over

Start with the match statement — it is not a mistake, it is correctly the cheapest thing
that works for two or three states. Move to per-state nodes only when a second enemy
type needs to reuse half the states, not before. And treat dead as a first-class
state from the start; bolting it on as a health check scattered across the codebase is
where the worst bugs hide.

None of this is really about state machines. It's about giving every transition exactly
one door in and one door out, so that when an enemy does something wrong, there is
exactly one function to put a breakpoint in.


If you'd rather drop this in than build it, Survivors Template does it as a ready-made tool: https://saltmire.itch.io/survivors-template-godot

Originally published at https://saltmire.github.io/godot-4-enemy-state-machine.html

Top comments (0)