Skip to content

M2.4 — A State Machine for the Player

What you'll learn

  • Replace boolean state flags with an explicit finite state machine.
  • Build a StateMachine node and State base (enter/exit/update), with states as child nodes.
  • Transition between states by signal, without a state knowing the machine's internals.

How it applies

  • Actor behavior is inherently stateful, and flags rot. "Can I move while attacking? Can I attack while hurt? What happens to input during death?" Every such question is a transition rule. Encoded as scattered booleans (is_attacking, is_hurt, is_dead), the rules contradict each other as they grow, producing bugs like attacking while dead. An FSM makes "exactly one state at a time" a structural guarantee.
  • States isolate, which testers want. With each state as its own node, the attack behavior can be reasoned about and exercised in isolation from move. That is the unit/integration boundary again: states are units, the machine is the integration.
  • The same machine is reused for enemies. M4's enemies are also idle/chase/attack/hurt/die — the exact shape built here. Investing in a clean FSM for the player pays out a second time on every enemy.
  • Designers tune per state. A node-based state can carry its own exported values (attack windup, hurt stun duration) editable in the Inspector, so balancing happens per state without touching shared code.

Try it first

Before reading the solution: you have a player that can be idle, moving, or (soon) attacking, and later hurt and dead. Sketch how you would organize that. What decides which behavior runs each frame? What stops the player from moving while attacking, or attacking while dead? Spend a few minutes writing down an approach before continuing — there is an obvious first answer (flags or an enum switch) and a reason the book moves past it.

Concepts

Why not flags

The first instinct is booleans: is_attacking, is_hurt. The trouble is that n booleans encode 2ⁿ combinations, most of which are nonsense (is_attacking and is_dead), and nothing prevents them. Every new behavior multiplies the contradictions, and the _physics_process fills with if not is_hurt and not is_dead and not is_attacking: guards that are easy to get wrong.

A finite state machine fixes this by definition: the actor is in exactly one state at a time, and transitions between states are explicit. There is no "attacking and dead" because those are two states and only one is active.

Two ways to build an FSM

Enum + match. Declare enum State { IDLE, MOVE, ATTACK }, keep var state: State, and switch on it each frame:

match state:
    State.IDLE:   _do_idle()
    State.MOVE:   _do_move()
    State.ATTACK: _do_attack()

This is a legitimate FSM and fine for two or three trivial states. Its limits show as states grow: all state logic lives in one script, each state's tuning values pile up as top-level variables, and a state cannot own its own child nodes (a Timer for attack windup). The single file becomes the thing the enum was supposed to prevent — a tangle.

States as child nodes (the Godot-idiomatic, vendor-endorsed approach). Each state is a Node child of a StateMachine node, implementing a shared interface (enter, exit, update). The machine holds the current state and forwards update(delta) to it; a state requests a transition by emitting a signal the machine listens to. Each state is isolated, can carry its own exported tuning and child nodes, and can be tested on its own.

This book uses the node approach. The enum version is the right tool only when you are certain the actor will stay trivially simple — which a player and enemies in an ARPG will not.

The State base and the StateMachine

The base state defines the interface every concrete state fills in:

# res://scripts/state.gd
class_name State
extends Node

## Emitted to ask the machine to switch to the named sibling state.
signal transitioned(next_state_name: StringName)

func enter() -> void: pass
func exit() -> void: pass
func update(delta: float) -> void: pass
func physics_update(delta: float) -> void: pass

The machine owns the current state and pumps the active one:

# res://scripts/state_machine.gd
class_name StateMachine
extends Node

@export var initial_state: State
var current: State

func _ready() -> void:
    for child in get_children():
        if child is State:
            child.transitioned.connect(_on_transitioned)
    current = initial_state
    if current:
        current.enter()

func _physics_process(delta: float) -> void:
    if current:
        current.physics_update(delta)

func _on_transitioned(next_name: StringName) -> void:
    var next := get_node_or_null(NodePath(next_name)) as State
    if next == null or next == current:
        return
    current.exit()
    next.enter()
    current = next

The machine knows nothing about idle or move specifically — it pumps whatever the current state is and swaps when a state asks. Adding a state is adding a child node and wiring its transitions; the machine is untouched. That is the property the enum switch lacks.

Concrete states

Idle and Move carry the behavior M2.1–M2.2 wrote, split by responsibility. They reach the player body through the machine's owner (the Player node), reading input and deciding when to hand off.

Example

The Idle state: no movement; the moment movement input appears, ask to transition to Move.

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

@onready var player: CharacterBody2D = owner

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

func physics_update(_delta: float) -> void:
    var direction := Input.get_vector("move_left", "move_right", "move_up", "move_down")
    if direction != Vector2.ZERO:
        transitioned.emit(&"Move")
    player.move_and_slide()

Example

The Move state: drive velocity from input; when input stops, hand back to Idle.

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

@onready var player: CharacterBody2D = owner

func physics_update(_delta: float) -> void:
    var direction := Input.get_vector("move_left", "move_right", "move_up", "move_down")
    if direction == Vector2.ZERO:
        transitioned.emit(&"Idle")
        return
    player.velocity = direction * player.speed
    player.sprite.play("run")
    if direction.x != 0.0:
        player.sprite.flip_h = direction.x < 0.0
    player.move_and_slide()

The &"Move" / &"Idle" are StringName literals matching the sibling state nodes' names. In M3, an Attack state joins these siblings, and both Idle and Move add a check that emits &"Attack" when the attack action fires — no change to the machine.

Where this leaves player.gd

The player body script shrinks: it owns speed, the @onready var sprite, and the references the states read, but its _physics_process movement logic moves into the states. The body becomes the shared context the states operate on; the states own the behavior. This is the refactor that makes M3's attack a new node rather than another flag in a growing function.

Walkthrough

  1. Create res://scripts/state.gd and res://scripts/state_machine.gd with the class_name'd base and machine shown above. Save.
  2. Create res://scripts/states/player_idle.gd and player_move.gd with the two states shown. Type them; don't paste.
  3. Open res://scenes/actor/player.tscn. Add a child of Player: a plain Node, renamed StateMachine, and attach state_machine.gd to it.
  4. Add two children of StateMachine: Nodes named Idle and Move, attaching player_idle.gd and player_move.gd respectively. (The node name is what the &"Idle" / &"Move" transitions resolve to — spelling matters.)
  5. Select StateMachine, and in the Inspector set its Initial State to the Idle node.
  6. In player.gd, remove the movement logic from _physics_process (the states own it now); keep speed, @onready var sprite, and the class declaration. Confirm owner resolves to Player for the states (it does, because the states are saved as part of player.tscn, whose root is Player).
  7. Press F5. The character idles and moves exactly as before — but now the behavior lives in states. The visible result is identical; the architecture changed underneath, which is the point.

Optional sanity check

Add a temporary print("enter ", name) to State-derived enter() overrides (or to the machine's _on_transitioned). Walk and stop a few times and watch the Output panel log Move/Idle transitions. Exactly one state should be active at any moment, and you should never see two enters without an intervening exit. Remove the prints when satisfied.

Self-check quiz

Q1 — Why prefer states-as-child-nodes over an enum + match switch as the actor grows?

A. match is slower than method calls. B. Each node-state is isolated — its own logic, exported tuning, and child nodes — and adding a state doesn't modify the machine; the enum switch concentrates all states in one growing script. C. Enums cannot represent more than three states. D. The engine forbids match in _physics_process.

Reveal answer

B. The node approach scales because each state is a self-contained unit and the machine is agnostic to which states exist; the enum approach funnels every state's logic and tuning into one file that grows into the tangle the FSM was meant to avoid. A is not the real reason (performance is comparable). C is false (enums hold any number). D is fabricated.

Q2 — In the node FSM, how does the Move state cause a switch to Idle without knowing how the machine performs the switch?

A. It calls machine.set_state(Idle) directly. B. It emits transitioned.emit(&"Idle"); the machine, which connected to that signal, looks up the sibling node named Idle and swaps. C. It frees itself and instances Idle. D. It sets a global next_state variable the machine polls.

Reveal answer

B. The state announces intent via a signal carrying the target state's name; the machine owns the actual transition (exit current, enter next). The state never references the machine's internals or the other state object — only a name. A couples the state to the machine's API; C is wrong (states persist, they aren't recreated each transition); D reintroduces shared mutable state the signal was meant to avoid.

Q3 — Why does the StateMachine script contain no mention of Idle or Move?

A. Because those states are defined in the engine, not the project. B. Because the machine is generic — it pumps whatever the current state is and swaps on request — so adding Attack later requires no change to the machine. C. Because naming states in the machine would cause a cyclic dependency error. D. It's an oversight; the machine should list them.

Reveal answer

B. Keeping the machine ignorant of specific states is deliberate: it makes the machine reusable (the same script runs the player and, in M4, enemies) and makes new states additive (drop in a node, wire its transitions). A is false. C is not why (no cycle is involved). D inverts the design — the absence is the feature.

Integration question

Q4 — open

M2 took the player from "a CharacterBody2D that reads input" (M2.1) to "a state machine of behaviors" (M2.4). Explain why M3 (attack) and M4 (enemies) are cheaper because of this chapter specifically: what does adding an Attack state to the player require, and how does the enemy in M4 reuse this machine despite reading no keyboard input?

Reveal expected answer

Because the machine is generic and states are isolated, adding the player's attack is additive: create an Attack state node with its own enter (play the attack animation, enable the M3 hitbox) and its own exit-to-Idle rule, and add one line to Idle/Move that emits &"Attack" when the attack action fires — the machine and the other states are untouched. M4's enemy reuses the same StateMachine and State base because nothing in them assumes keyboard input; an enemy simply has different concrete states (Chase, Attack, Hurt, Die) whose physics_update reads the player's position and a detection result instead of Input. The investment in a clean, input-agnostic FSM here is what lets the player's attack be a node and the enemy's brain be the same machine with different children — the loose-coupling and reuse the whole book is built on.