{"slug": "what-i-learned-building-an-enemy-state-machine-in-godot-4", "title": "What I learned building an enemy state machine in Godot 4", "summary": "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.", "body_md": "I wrote \"just use a match statement, it's fine\" three times before I stopped saying it. It\n\nis fine, right up until an enemy needs a fourth state and two of the transitions start\n\ndepending on each other. Here is what actually cost time building enemy AI for a\n\nwave-based game, in the order it bit me.\n\nA two-state enemy — chase, attack — is genuinely not worth a framework:\n\n``` php\nfunc _physics_process(delta: float) -> void:\n    match state:\n        State.CHASE:\n            velocity = (player.global_position - global_position).normalized() * speed\n            if global_position.distance_to(player.global_position) < attack_range:\n                state = State.ATTACK\n        State.ATTACK:\n            attack_timer -= delta\n            if attack_timer <= 0.0:\n                do_attack()\n                state = State.CHASE\n```\n\nThe moment a third and fourth state show up — hurt, dead, stagger, windup — the match\n\nblock stops being one enemy's logic and becomes a grid of every state times every other\n\nstate it might transition to. That grid is where the bugs live, not in any single state.\n\nEvery state-machine bug I actually spent time on was the same shape: state A left some\n\nflag or timer set that state C didn't know to check. An enemy stuck mid-attack-animation\n\nforever, still receiving hits, was not a bug in the attack state — it was the hurt state\n\ninterrupting attack without cleaning up `attack_timer`\n\nor resetting the animation.\n\nThe fix that made these bugs findable is giving every state an explicit `enter`\n\nand\n\n`exit`\n\n, and never mutating another state's data directly:\n\n``` php\nfunc change_state(new_state: State) -> void:\n    if new_state == state:\n        return\n    _exit_state(state)\n    state = new_state\n    _enter_state(new_state)\n\nfunc _exit_state(s: State) -> void:\n    match s:\n        State.ATTACK:\n            attack_timer = 0.0\n            sprite.stop()\n\nfunc _enter_state(s: State) -> void:\n    match s:\n        State.HURT:\n            velocity = Vector2.ZERO\n            hurt_timer = HURT_DURATION\n            sprite.play(\"hurt\")\n```\n\nOne entry point for every transition means you can put a single `print`\n\nin\n\n`change_state`\n\nand see the entire life of an enemy in the log, instead of guessing which\n\nof six scattered `state = X`\n\nlines fired.\n\nI resisted the State-as-a-Node pattern for a long time — it felt like ceremony for\n\nsomething a match statement already did. It earns its cost the moment you have more than\n\none enemy *type* sharing behavior: a ranged enemy and a melee enemy both need chase and\n\nhurt, but attack is completely different.\n\n``` php\nclass_name EnemyState\nextends Node\n\nfunc enter(_enemy: Node) -> void: pass\nfunc exit(_enemy: Node) -> void: pass\nfunc physics_update(_enemy: Node, _delta: float) -> void: pass\n\nclass_name ChaseState\nextends EnemyState\n\nfunc physics_update(enemy: Node, delta: float) -> void:\n    enemy.velocity = enemy.direction_to_player() * enemy.speed\n    if enemy.in_attack_range():\n        enemy.state_machine.change_to(\"attack\")\n```\n\nNow `attack`\n\nis swappable per enemy scene without touching `chase`\n\nor `hurt`\n\nat all. For\n\na single enemy type, this is genuinely more code than a match statement for no benefit —\n\nthe crossover point is real, not just taste.\n\nThe bug that took longest to track down: an enemy could take lethal damage, enter\n\n`hurt`\n\n, and the hurt-state's timer would flip it back to `chase`\n\n— bringing a corpse back\n\nto life for one frame before `queue_free()`\n\ncaught up. `dead`\n\nwas handled as an\n\n`if health <= 0: queue_free()`\n\ncheck scattered in three places instead of being a real\n\nstate in the machine.\n\n``` php\nfunc take_damage(amount: int) -> void:\n    health -= amount\n    if health <= 0:\n        change_state(State.DEAD)   # one path in, nothing else can override it\n        return\n    change_state(State.HURT)\n```\n\nOnce `dead`\n\nwas a state like any other — with its own `enter()`\n\nthat disables the\n\nhitbox, stops physics, and plays the death animation before freeing — the flicker-back-\n\nto-life bug had nowhere left to come from.\n\nStart with the match statement — it is not a mistake, it is correctly the cheapest thing\n\nthat works for two or three states. Move to per-state nodes only when a second enemy\n\n*type* needs to reuse half the states, not before. And treat `dead`\n\nas a first-class\n\nstate from the start; bolting it on as a health check scattered across the codebase is\n\nwhere the worst bugs hide.\n\nNone of this is really about state machines. It's about giving every transition exactly\n\none door in and one door out, so that when an enemy does something wrong, there is\n\nexactly one function to put a breakpoint in.\n\nIf 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)\n\n*Originally published at https://saltmire.github.io/godot-4-enemy-state-machine.html*", "url": "https://wpnews.pro/news/what-i-learned-building-an-enemy-state-machine-in-godot-4", "canonical_source": "https://dev.to/saltmire/what-i-learned-building-an-enemy-state-machine-in-godot-4-2h3d", "published_at": "2026-09-04 03:29:44+00:00", "updated_at": "2026-09-04 03:53:03.750295+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Godot 4"], "alternates": {"html": "https://wpnews.pro/news/what-i-learned-building-an-enemy-state-machine-in-godot-4", "markdown": "https://wpnews.pro/news/what-i-learned-building-an-enemy-state-machine-in-godot-4.md", "text": "https://wpnews.pro/news/what-i-learned-building-an-enemy-state-machine-in-godot-4.txt", "jsonld": "https://wpnews.pro/news/what-i-learned-building-an-enemy-state-machine-in-godot-4.jsonld"}}