M2.4 — A State Machine for the Player¶
What you'll learn
- Replace boolean state flags with an explicit finite state machine.
- Build a
StateMachinenode andStatebase (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
attackbehavior can be reasoned about and exercised in isolation frommove. 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:
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¶
- Create
res://scripts/state.gdandres://scripts/state_machine.gdwith theclass_name'd base and machine shown above. Save. - Create
res://scripts/states/player_idle.gdandplayer_move.gdwith the two states shown. Type them; don't paste. - Open
res://scenes/actor/player.tscn. Add a child ofPlayer: a plainNode, renamedStateMachine, and attachstate_machine.gdto it. - Add two children of
StateMachine:Nodes namedIdleandMove, attachingplayer_idle.gdandplayer_move.gdrespectively. (The node name is what the&"Idle"/&"Move"transitions resolve to — spelling matters.) - Select
StateMachine, and in the Inspector set its Initial State to theIdlenode. - In
player.gd, remove the movement logic from_physics_process(the states own it now); keepspeed,@onready var sprite, and the class declaration. Confirmownerresolves toPlayerfor the states (it does, because the states are saved as part ofplayer.tscn, whose root isPlayer). - 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.