doc 5 of 5

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#

  1. Add suspend() and resume() to state.gd.
  2. Replace current with the stack above in state_machine.gd.
  3. Add a Stunned state: enter() zeroes horizontal velocity and starts a short timer; the timer's timeout calls pop() on the machine.
  4. 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 push Stunned, the stun ends and pops. Does Jump.enter() run again? Should it? Write down both answers before checking — they are not the same question, and the gap between them is why resume() 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() called exit() instead of suspend()?

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 _process right 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 Timer that kept running while suspended. That's exactly the job suspend()/resume() exist for.
  • current is null at startup → you never pushed an initial state. _ready() should push(initial), not assign to currentcurrent has 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.