Part 04 — Interrupt and resume
You will build: a state stack — states that push on top of what's running, then pop off and hand control back to whatever was underneath, untouched.
The symptom that sends you here#
You are writing this:
var previous_state: StringName # ← the tell
You wrote it because something interrupts gameplay and then has to give control back: an NPC starts talking, the player gets stunned, a cutscene plays, a menu opens. A plain machine can only replace the current state, so you save the old one in a variable and restore it later.
That works for one level of interruption. Then the player gets stunned during a dialogue, and
you need previous_previous_state. At that moment you have discovered that you needed a stack
all along — you were just implementing one badly, with a fixed depth of one.
The idea#
Instead of one current state, keep an array. The last element is what runs. Two new operations:
- push — suspend what's running, run something new on top
- pop — discard the top, resume whatever it was covering
Plain change_to() still exists and still means "replace the top." Most transitions are still
replacements; pushing is for genuine interruptions. This is a
pushdown automaton, and the stack is the
entire difference.
Suspended is not the same as exited#
This is the part that bites people. When Dialogue pushes on top of Run, the Run state
should not run exit() — it isn't finished, it's paused. If you call exit() you'll undo
setup that Run expects to still be true when it resumes, and the character will resume in a
subtly wrong condition.
So the base State gets two more optional hooks:
func suspend() -> void: # something pushed on top of me
pass
func resume() -> void: # the thing on top of me popped
pass
Most states leave both empty. States that own something time-based — a dash timer, a running
animation — use them to pause and unpause it. The distinction is: exit() means "I'm done
and won't be back"; suspend() means "hold my place."
The machine, extended#
Building on part 03's StateMachine. current becomes a stack, and everything reads the top:
var _stack: Array[State] = []
var current: State:
get: return _stack.back()
func push(state_name: StringName) -> void:
var next: State = _states.get(state_name)
if next == null:
push_error("no state named '%s'" % state_name)
return
if not _stack.is_empty():
current.suspend() # NOT exit()
_stack.push_back(next)
next.enter()
func pop() -> void:
if _stack.size() <= 1:
push_error("refusing to pop the last state — the machine would have nothing to run")
return
_stack.pop_back().exit() # this one really is finished
current.resume()
func change_to(state_name: StringName) -> void:
# replace the top: the old top is genuinely done
var next: State = _states.get(state_name)
if next == null:
push_error("no state named '%s'" % state_name) # stay loud, exactly as in part 03
return
if next == current:
return
_stack.pop_back().exit()
_stack.push_back(next)
next.enter()
current is now a computed property reading the back of the array, so every line in part 03
that reads current keeps working unchanged — _process and _physics_process still forward
to it and never learn that a stack exists.
Lines that assigned to current are a different story: it's getter-only now, so those
become errors and must go through push() or change_to(). That's the refactor doing its job —
the compiler is pointing at every place that used to bypass the stack.
The guard in pop() is not paranoia. An empty stack means current returns null and the
next frame crashes in _process — far away from the pop that caused it. Fail loudly at the
cause.
Build it#
- Add
suspend()andresume()tostate.gd. - Replace
currentwith the stack above instate_machine.gd. - Add a
Stunnedstate:enter()zeroes horizontal velocity and starts a short timer; the timer'stimeoutcallspop()on the machine. - Trigger it from anywhere — a key press is fine for testing.
Now test the thing that motivated all this: get stunned mid-jump. The player should finish the jump arc afterwards, not teleport into idle on the ground.
Predict before you run: you are in
Jump, you pushStunned, the stun ends and pops. DoesJump.enter()run again? Should it? Write down both answers before checking — they are not the same question, and the gap between them is whyresume()exists.
When not to reach for this#
Pushing looks elegant, which makes it easy to overuse. Two honest limits:
Pausing the whole game is not a stack problem. If the interruption should freeze
everything — enemies, projectiles, timers — Godot already does that:
get_tree().paused
plus the right process_mode on the menu. A Paused state on the player's machine stops the
player and nothing else.
Two independent concerns are still two machines. If you're pushing Reloading on top of
Run so the character can run while reloading, you don't want a stack — you want a movement
machine and a weapon machine running side by side. A stack means "this replaces attention
temporarily," not "these happen together."
Where the whole ladder runs out#
Worth knowing the ceiling before you hit it, because the symptom is easy to misread as your own sloppiness.
Orthogonality. Movement (Idle, Run, Jump, Fall, WallSlide, Dash) and combat (Ready,
Attacking, Hurt, Invulnerable) are genuinely independent — you can be attacking while falling.
One flat machine has to enumerate the combinations: 6 × 4 = 24 states. Nobody writes 24 states.
What they actually do is add var is_attacking: bool to the character — and that is the boolean
soup from part 01, walking back in through the door the state machine was supposed to lock. Two
parallel machines (6 states and 4 states) is the honest fix, and it's why part 03 insisted the
machine be reusable.
Hierarchy. Coyote time, double jump and variable jump height are all airborne concerns, so
gravity and the landing check get copy-pasted into Jump, Fall, DoubleJump and Dash. Worse,
"getting hit sends you to Hurt" has to be wired from every single state. What you want is to say
it once on a parent Airborne state and have substates inherit it.
That's what statecharts add — nested states, parallel regions, and transitions that inherit — from David Harel's 1987 formulation. In Godot the mature option is godot_state_charts (MIT), which does hierarchy and parallel regions properly.
Don't install it yet. Reach for it when you have personally written var is_attacking: bool
next to a state machine and felt the contradiction — not before. An addon adopted before you've
met the problem it solves is a second thing to learn instead of a solution, and it moves your
transition logic into scene data, where it's harder to diff and review. The ladder in this shelf
covers the overwhelming majority of 2D characters; this paragraph exists so you recognise the
exit when you reach it.
Checkpoint — definition of done#
- Getting stunned mid-jump resumes the jump, preserving vertical velocity
- Popping the last remaining state prints an error instead of crashing a frame later
- A state that pushes something knows nothing about what it pushed onto
- You can explain: what would visibly break if
push()calledexit()instead ofsuspend()?
Stretch (no instructions)#
Push Stunned while already stunned. Decide what should happen — refuse, restart the timer,
or genuinely stack two stuns — then implement your choice and write down why. There is no
single right answer, and knowing that is the point.
If you get stuck#
- Crash in
_processright after a pop → the stack emptied. Print_stack.size()on every push and pop and read them as a pair; they should balance like brackets. - Resumed state behaves wrongly → it owned a
Timerthat kept running while suspended. That's exactly the jobsuspend()/resume()exist for. currentis null at startup → you never pushed an initial state._ready()shouldpush(initial), not assign tocurrent—currenthas no setter any more.
That's the ladder. Back to the overview, or see the rung below this one applied in the platformer project, which uses part 03's node machine — and, deliberately, no stack. Its states all replace one another, which is the common case.