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. 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: php 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: php 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. php 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. php 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 https://saltmire.itch.io/survivors-template-godot Originally published at https://saltmire.github.io/godot-4-enemy-state-machine.html