Part 03 — When one file isn't enough
You will build: a reusable StateMachine node that runs one State child node at a time —
the same character behaviour as part 02, but with each state in its own file and its own
Inspector-tunable values.
Read this only when part 02 hurts. The honest trigger is one of these two:
- A single state's logic is long enough that you scroll past it to find other states.
- You want per-state numbers designers (or future-you) can tune without opening code — dash speed, attack duration, wall-slide friction.
If neither is true, the enum version is better, not merely acceptable: fewer files, no indirection, the whole machine visible on one screen. Godot's own documentation has no opinion on state machines at all — this pattern is community convention, not engine doctrine, and it earns its place per-project or not at all.
The shape#
Player (CharacterBody2D)
├── Sprite2D
├── CollisionShape2D
└── StateMachine (Node)
├── Idle (Node, idle_state.gd)
├── Run (Node, run_state.gd)
├── Jump (Node, jump_state.gd)
└── Attack (Node, attack_state.gd)
Each state is a node. The machine keeps exactly one of them "current" and forwards the frame
callbacks to it. Adding a state is adding a child — no existing file changes, which is the
property that made the enum version's match blocks grow.
The base class#
state.gd — every state extends this. It is deliberately almost empty:
class_name State
extends Node
## Emitted when this state decides it is over. The machine listens and switches.
signal finished(next_state: StringName)
## Injected by the machine at startup so states can reach the body they drive.
var player: CharacterBody2D
func enter() -> void:
pass
func exit() -> void:
pass
func update(_delta: float) -> void:
pass
func physics_update(_delta: float) -> void:
pass
Two things to notice, because both are deliberate:
States never switch themselves. A state emits finished and stops caring. It does not call
machine.change_to(), does not know the machine's type, and cannot be broken by being reused
under a different machine. This is "signal up" — the child reports, the parent decides.
player is injected, not fetched. A state that calls get_parent().get_parent() is
hard-wired to one exact tree shape, and the day you nest it one level deeper it breaks
silently. The machine hands each state what it needs.
The machine#
state_machine.gd:
class_name StateMachine
extends Node
@export var initial_state: State
var current: State
var _states: Dictionary[StringName, State] = {}
func _ready() -> void:
var player := owner as CharacterBody2D
# register every child state and wire its exit signal
for child in get_children():
var state := child as State
if state == null:
push_warning("%s is not a State — ignored" % child.name)
continue
_states[StringName(child.name)] = state
state.player = player
state.finished.connect(_on_state_finished)
current = initial_state if initial_state != null else _states.values()[0]
current.enter()
func _process(delta: float) -> void:
current.update(delta)
func _physics_process(delta: float) -> void:
current.physics_update(delta)
func change_to(state_name: StringName) -> void:
var next: State = _states.get(state_name)
if next == null:
push_error("no state named '%s'" % state_name)
return
if next == current:
return
current.exit()
current = next
current.enter()
func _on_state_finished(next_state: StringName) -> void:
change_to(next_state)
owner is the root of the scene this node was saved in — the Player — so the machine finds
the body without any hard-coded path.
The parameter is state_name, not name, for a small but real reason: every Node already has
a name property, and shadowing it raises a SHADOWED_VARIABLE_BASE_CLASS warning. Warnings you
learn to scroll past stop being warnings, so it's worth never creating one.
The stringly-typed trap, named out loud#
change_to(&"Jump") matches states by node name, which is a string. Rename the node and
nothing complains until the game runs and hits that transition. This is a real cost, and it is
the standard criticism of this pattern.
Three ways to live with it, worst to best:
- Plain strings everywhere. Don't.
StringNameliterals (&"Jump") — what's written above. Faster comparisons, and the&makes it visibly a name rather than arbitrary text. Still not checked.const JUMP := &"Jump"declared once on the machine, referenced everywhere. A typo is now a compile-time error instead of a runtime one.
Do (3) in anything you intend to keep. It costs one line per state and removes the only genuinely dangerous property of the pattern.
A concrete state#
jump_state.gd — and here's the payoff for the extra files:
class_name JumpState
extends State
@export var jump_velocity: float = -330.0
@export var gravity: float = 980.0
@export_range(0.0, 1.0) var air_control: float = 0.8
@export var speed: float = 180.0
func enter() -> void:
player.velocity.y = jump_velocity
func physics_update(delta: float) -> void:
# gravity + reduced steering while airborne
player.velocity.y += gravity * delta
var direction: float = Input.get_axis("move_left", "move_right")
player.velocity.x = direction * speed * air_control
player.move_and_slide()
# landing ends the state; the machine decides what comes next
if player.is_on_floor() and player.velocity.y >= 0.0:
finished.emit(&"Idle")
Select the Jump node in the editor and air_control is a slider. Tuning jump feel no longer
means editing code, and every state's numbers sit next to the state they belong to instead of
in one shared block of constants at the top of a long file.
Build it#
- Create
state.gdandstate_machine.gdas above. - Build the tree:
StateMachineunderPlayer, fourNodechildren namedIdle,Run,Jump,Attack. - Write
idle_state.gdandrun_state.gdyourself — they're short, and between them they need exactly two transitions. - Write
attack_state.gd. It owns its ownTimeras a child;enter()starts it, itstimeoutemitsfinished(&"Idle"). - Set
initial_statein the Inspector to theIdlenode.
Predict before you run: which node's
_physics_processruns each frame — the machine's, the current state's, both, or neither? Write your answer down, then check by printing in both. Getting this wrong is the most common reason a state "does nothing".
Now read someone else's — as a code review#
This pattern isn't ours. The best-known Godot version is
GDQuest's finite state machine,
with the source in
gdquest-demos/godot-design-patterns
under godot/finite_state_machine/node_version/. Their code is MIT
(© 2020-present GDQuest); their tutorial prose is CC-BY 4.0. You can read, quote and adapt both
with credit — this page does exactly that, with changes.
Read it now, not earlier. Handed to you before you'd written your own, its cleverest lines look like noise. Having written yours, they read as answers to problems you just had. Three are worth the trip:
await owner.ready — in their _ready(), before entering the first state. Children are
ready before their parents in Godot, so a state machine that starts immediately touches a
half-built character. If your machine crashes on startup reaching for something on the player,
this is why, and it's a bug most people never diagnose — they just move code around until it
stops.
find_children("*", "State") — they discover states by type, not by name or by iterating
get_children(). Nested states get picked up for free.
finished(next_state_path, data) — same idea as ours: the state announces and the machine
decides. Theirs also carries a data dictionary, so a state can hand the next one a payload
(the impact normal, the damage that caused the stun). That's a genuinely good idea and it costs
one parameter.
And one thing to criticise, because reading code well means seeing the costs too: their
transitions are node paths as strings, resolved at runtime with has_node() and failing via
printerr — the same stringly-typed weakness named above, and a fair thing to disagree with.
One practical note: the tutorial page's listing omits the
state_changedsignal that the repository file has. Read the file, not the page.
Diff yours against theirs and write down three differences and why each exists. That comparison teaches more than either file alone, and it's a skill worth more than this pattern: reading someone else's solution as a peer rather than as an authority.
What not to do: copy their folder in and start from it. The file is about forty lines. Reading a fork of it costs more than writing it, and every insight above only lands because you'd already met the problem. Licences permit the shortcut; learning doesn't.
Checkpoint — definition of done#
- The character behaves exactly as it did in part 02 — no new features, no regressions
- Changing
air_controlin the Inspector visibly changes air steering without touching code - Adding a fifth state required editing zero existing state files
- Renaming a state node and fixing the resulting error taught you why constants beat literals
- You can explain: why does a state emit
finishedinstead of callingchange_to()itself?
Stretch (no instructions)#
Give the machine a state_changed(from, to) signal and have a debug Label display the
current state name. Then answer this: could you now drop this exact StateMachine node onto an
enemy with completely different states and have it work unchanged? If not, find the line that
assumes it's driving the player, and fix it.
If you get stuck#
- Nothing happens at all → is
initial_stateassigned in the Inspector? Iscurrent.enter()reached? Print in_ready(). playeris null inside a state →owneris null when a node isn't part of a saved scene's tree, which happens if you built the machine by script instead of in the editor. Printownerin_ready().- Two states run at once → you left
_physics_processcode in thePlayerscript and in a state. The machine drives states; the body should be nearly empty. move_and_slide()called twice per frame → decide once and for all who calls it: every state, or the player after the machine updates. Both is a bug that shows up as double-speed movement.
Next: Part 04 — interrupt and resume, for when a state needs to come back to what it was doing.