cd /news/developer-tools/what-i-learned-building-an-enemy-sta… · home topics developer-tools article
[ARTICLE · art-121063] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

What I learned building an enemy state machine in Godot 4

A developer detailed the pitfalls of building enemy state machines in Godot 4, emphasizing that explicit enter/exit methods and a centralized change_state function prevent bugs like stuck animations and resurrecting corpses. The post advocates for state-as-a-node patterns when multiple enemy types share behavior, noting the crossover point where the added structure pays off.

read4 min views1 publishedSep 4, 2026

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.

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

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.

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")

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.

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")

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.

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)

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.

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

── more in #developer-tools 4 stories · sorted by recency
── more on @godot 4 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/what-i-learned-build…] indexed:0 read:4min 2026-09-04 ·