doc 4 of 5

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:

  1. Plain strings everywhere. Don't.
  2. StringName literals (&"Jump") — what's written above. Faster comparisons, and the & makes it visibly a name rather than arbitrary text. Still not checked.
  3. 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#

  1. Create state.gd and state_machine.gd as above.
  2. Build the tree: StateMachine under Player, four Node children named Idle, Run, Jump, Attack.
  3. Write idle_state.gd and run_state.gd yourself — they're short, and between them they need exactly two transitions.
  4. Write attack_state.gd. It owns its own Timer as a child; enter() starts it, its timeout emits finished(&"Idle").
  5. Set initial_state in the Inspector to the Idle node.

Predict before you run: which node's _physics_process runs 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_changed signal 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_control in 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 finished instead of calling change_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_state assigned in the Inspector? Is current.enter() reached? Print in _ready().
  • player is null inside a state → owner is 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. Print owner in _ready().
  • Two states run at once → you left _physics_process code in the Player script 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.