Stage 02 — The state machine: built generic, nestable by accident
You will build: the two generic classes — State and StateMachine extends State —
from scratch, then the player's IdleState and WalkState, and the player refactored to
run on the machine.
You'll learn: registration and why the node name is the API · travel() and the
finished signal · StateMachine extends State — nesting for free · the two traps this
project actually hit.
Why this exists#
Stage 01's player is one _physics_process with an if/else: move or don't. Stage 03 adds
run; stage 04 adds use item; the architecture doc reserves shapeshift. The
state machine shelf ladders the options — plain ifs,
enum + match, node states, a stack — and says: climb only when the rung hurts. This project
climbs straight to the node rung, and it does so by building the machine itself rather than
receiving one, which is the point: the generic State/StateMachine pair is ~60 lines, and
a state machine you haven't typed is a state machine you can't debug.
(Choosing this rung over the enum machine is design decision 2 — record the call in the architecture doc. The one-line trade-off: A is less ceremony; B is where shapeshift lives.)
Build it#
A — State, the thing a state is#
scripts/fsm/state.gd:
class_name State
extends Node
signal finished(next_state: StringName)
var machine: StateMachine
var actor: Node
func _setup() -> void:
pass
func _enter_state() -> void:
pass
func _exit_state() -> void:
pass
func _update(_delta: float) -> void:
pass
func _physics_update(_delta: float) -> void:
pass
A state is a node with two references and five hooks:
machine— who owns me (set at registration, used totravel).actor— the entity the machine serves (the player). States read the world through the actor, which is what keeps them generic: aWalkStatedoesn't know it's walking a farmer, it callsactor.input_direction()._setup(once, at registration) ·_enter_state/_exit_state(every transition) ·_update/_physics_update(every tick, while current). The underscore prefix is the same "private door" convention the survivor game's magnet met in stage 06: the machine calls these; outside code doesn't.finished(next_state)— a state that completes a job announces its return. The machine listens; the state doesn't poll.
B — StateMachine, the owner#
scripts/fsm/state_machine.gd:
class_name StateMachine
extends State
## A state that owns other states. Nest one inside another to build a hierarchy.
@export var initial_state: State = null
var current: State
var _states: Dictionary[StringName, State] = {}
func _ready() -> void:
set_process(false)
set_physics_process(false)
if get_parent() is StateMachine:
return
actor = get_parent()
_setup()
await owner.ready
_enter_state()
set_process(true)
set_physics_process(true)
func _process(delta: float) -> void:
_update(delta)
func _physics_process(delta: float) -> void:
_physics_update(delta)
func _setup() -> void:
for child in get_children():
if child is State:
var child_state := child as State
_states[StringName(child_state.name)] = child_state
child_state.machine = self
child_state.actor = actor
child_state.finished.connect(_on_state_finished)
child_state._setup()
if initial_state == null:
initial_state = child_state
assert(not _states.is_empty(), "%s has no State children — did you forget a script?" % name)
func _enter_state() -> void:
current = initial_state
if current != null:
current._enter_state()
func _exit_state() -> void:
if current != null:
current._exit_state()
current = null
func _update(delta: float) -> void:
if current != null:
current._update(delta)
func _physics_update(delta: float) -> void:
if current != null:
current._physics_update(delta)
func travel(to: StringName) -> void:
if not _states.has(to):
if machine != null:
machine.travel(to)
else:
assert(false, "%s has no state named '%s'" % [name, to])
return
if _states[to] == current:
return
if current != null:
current._exit_state()
current = _states[to]
current._enter_state()
func _on_state_finished(next_state: StringName) -> void:
travel(next_state)
The parts that earn their lines:
extends State. The machine is a state, which is the entire nesting feature: drop aStateMachinenode inside aStatenode and the parent machine registers it as an ordinary child state, and the child machine runs its own children. Shapeshift (stage 07's requirements) is a state that contains a machine — no second class, no second pattern.- Registration walks
get_children()and keys eachStateby its node name:_states[StringName(child_state.name)]. The name is the transition API —travel(&"WalkState")addresses a node. Two traps the reference project hit, both recorded in the code reference:- A state node with no script is silently skipped —
child is Stateis false, no assertion fires (the machine has other states), and the transition key simply doesn't exist. The shipped scene'sSynthesizeStateis exactly this: a node with no script, present in the tree, absent from the machine. If that surprises you, it will again — that's what the record is for. - Renaming a state node renames its API. The
assertat the end of_setupis the loud version of trap 1: a machine with zero registered states fails at boot, naming the file.
- A state node with no script is silently skipped —
travel()bubbles. An unknown name goes to the parent machine before asserting — which is how a nested machine's states can be reached from outside without knowing the nesting exists, and how the top-level assert is the one that fires.await owner.readybefore entering the initial state: the machine's_readyruns before the actor's children are all ready; the first_enter_statemay read actor properties, so wait for the actor to be finished. Oneawait, and a whole class of "works in the editor, breaks on scene switch" disappears.set_process(false)until after setup — a machine (or a nested one) must not tick states before registration finishes.
C — The player states#
entities/player/states/ — a base, then the two you need:
# player_state.gd
class_name PlayerState
extends State
var player: Player
func _setup() -> void:
player = actor as Player
# idle_state.gd
class_name IdleState
extends PlayerState
func _physics_update(_delta: float) -> void:
if player.input_direction() != Vector3.ZERO:
machine.travel(&"WalkState")
# walk_state.gd
class_name WalkState
extends PlayerState
func _physics_update(_delta: float) -> void:
var direction := player.input_direction()
player.move(direction, player.speed, player.acceleration, _delta)
if direction == Vector3.ZERO:
machine.travel(&"IdleState")
PlayerState._setup does the one cast the whole player machine needs — actor as Player —
and every concrete state gets a typed player for free. That's the payoff of the actor
reference: the generic machine never knows the type; the one base state class does, once.
Now the player sheds its stage-01 _physics_process and keeps only what states need:
# player_controller.gd — after the refactor
class_name Player
extends CharacterBody3D
@export var speed := 4.0
@export var run_multiplier := 1.6
@export var acceleration := 9.0
@export var deceleration := 11.5
@onready var skin: Node3D = $Skin
var facing: Vector3 = Vector3.FORWARD
func input_direction() -> Vector3:
var raw := Input.get_vector(&"move_left", &"move_right", &"move_forward", &"move_back")
if raw == Vector2.ZERO:
return Vector3.ZERO
return Vector3(raw.x, 0.0, raw.y).normalized()
func move(direction: Vector3, target_speed: float, velo: float, delta: float) -> void:
velocity.x = move_toward(velocity.x, direction.x * target_speed, velo * delta)
velocity.z = move_toward(velocity.z, direction.z * target_speed, velo * delta)
if not is_on_floor():
velocity += get_gravity() * delta
move_and_slide()
The player is now a provider: states ask it for input and for movement, and the machine's
_physics_process (inherited from State) calls the current state's _physics_update each
tick. No _physics_process on the player at all. If you catch yourself wanting to put one
back, that's the machine's job leaking out — name what you actually want (facing? run?) and
give that a home in a state or in move() (stage 03).
Scene wiring: in Player.tscn, add a StateMachine child (Node + the script) and, under
it, IdleState and WalkState (Nodes + their scripts). Set the machine's
initial_state = IdleState in the inspector (or leave it null — the first child wins).
Run: walk → WalkState, release → IdleState. The transition is a travel, the ticks go
to exactly one state, and the player has no idea which one.
(The two states that aren't built yet are in the picture because the machine's shape is a
design artifact — the diagram is the spec, and stages 03–04 are where the grey comes alive.
ShapeshiftState is deliberately not drawn: it's a nested machine, and one flat row can't
show nesting without lying.)
D — The reserved stub#
Add a ShapeshiftState node under the machine with an empty script:
# shapeshift_state.gd
class_name ShapeshiftState
extends PlayerState
Nothing travels to it. It exists so the scene tree — and the architecture doc — can point at a place for stage 07's nested machine. An unreachable state is documentation with a node name; an absent state is a hope.
E — Commit#
git add . && git commit -m "stage 02: generic nestable state machine, Idle/Walk states"
Checkpoint — definition of done#
- Walk/release transitions fire, and in the debugger you can watch
currentchange — printmachine.current.namefor one frame in each state - Delete
WalkState's script and run: the assert names the machine and the reason ("did you forget a script?"). Restore it. This is trap 1, met on purpose. - Rename
WalkStatetoWalkingStatein the scene and don't updatetravel(&"WalkState"): the assertion fires intravel, naming the missing key. Rename it back. This is trap 2, met on purpose. - Add a third machine inside
ShapeshiftStatewith one child state, and watch the parent machine register the child machine as a state — nesting is inheritance, not a feature - The player has no
_physics_process; the machine does, viaState - Decision 2 recorded with your reason
- Zero warnings; committed
Stretch (no instructions)#
A DiveState that no input reaches — but whose travel is reachable by name from the
debug console (machine.travel(&"DiveState") in the debugger's script view). A state you
can only enter by API is the seam every future interrupt (a boss grab, a cutscene) will use;
build the seam before you need it.
If you get stuck#
- The player does nothing on run → the machine's
set_process(false)never got itsset_process(true), which means_readybailed early:get_parent() is StateMachinewas true (the machine is nested where it shouldn't be), or theawait owner.readyhung (the owner is null because the state nodes live in a separate scene that was instantiated wrong). Check the machine's parent is the player, not another machine. - States tick at the same time → a state node has its own
_physics_processand the machine routes to_physics_update. States never run their own process callbacks; the machine is the only clock. (The machine disables its own process until setup for the same reason: one clock, started once.) travel(&"WalkState")asserts on first input but works later → registration ran before the child states were added (you added them by code after_readyinstead of in the scene). The scene tree is the registration list; build it in the editor.- The player faces the wrong way or the camera spins (stage 01's prediction, cashing in) →
stage 03 rotates the
Skin, and the architecture's decision 1 decides the camera's real parent. Don't rotate the body to fix a camera problem with a facing problem.