doc 7 of 10

Stage 05 — The machine

You will build: exactly what you already have — running, jumping, coyote time, buffering — restructured so each behaviour lives in its own state node.

You'll learn: node-based state machines · refactoring under a no-behaviour-change rule · design decision 1 from the architecture document comes due.

Warm-up — from memory:

  1. What does _var_jump_timer do while above zero, and what ends it early?
  2. From stage 03: name the two timers and say which is refilled every grounded frame.
  3. From the farming game: in an enum state machine, what did _set_state()'s early return prevent?

Why this exists#

Open player.gd and count. Running, gravity, variable jump, apex, coyote, buffering, respawn — in one _physics_process that no longer fits on a screen. Stage 06 adds a dash and stage 07 adds three wall behaviours. Adding those here means a function nobody can hold in their head.

You've done the enum version before, in the farming game. That's the right rung for three to six short states. This character is past it: each state now has real per-frame logic and its own tuning values, which is exactly the trigger part 03 of the state-machine shelf names for climbing a rung.

The rule for this stage: nothing may feel different at the end. No new mechanics, no "while I'm in here" fixes. A refactor that also changes behaviour is two changes that can't be told apart when one of them breaks something.

Read this first#

The full worked pattern — the State base class, the StateMachine node, injection, and the stringly-typed trap — lives in Systems → State Machines, part 03. Read it now; it's the reference implementation, and this stage assumes it.

What follows is what's specific to this character.

Build it#

A — The states#

Player (CharacterBody2D)
├── Sprite2D
├── CollisionShape2D
└── StateMachine (Node)
    ├── Idle   ← grounded, no input
    ├── Run    ← grounded, moving
    ├── Jump   ← rising
    └── Fall   ← descending

Four to start. Dash arrives in stage 06, WallSlide in stage 07 — and if this structure is right, adding them will require editing no existing state file.

B — What belongs where#

This is the design work, and it's worth doing on paper before you type.

Stays on the Player: the tuning resource, velocity (it's CharacterBody2D's), the coyote and buffer timers, and the helper that reads horizontal input. These are true of the character in every state, so putting them in a state means duplicating them into all of them.

Moves into states: the gravity application, the variable-jump clamp, the apex condition, the decision about what to become next.

The test for each piece of code: would this be identical in every state? Yes → the player. No → the state that needs it.

Coyote time is the interesting case. It's tempting to put it in Fall — that's where it's consulted. But it's refilled while grounded, which happens in Idle and Run too. Something true in three of four states belongs to the player.

C — Transitions#

Draw this on paper before writing it. Genuinely — the drawing is faster than the debugging.

FromToWhen
IdleRunhorizontal input is non-zero
Idle / RunJumpa buffered jump and coyote time remain
Idle / RunFallnot on floor
RunIdleinput is zero and horizontal speed has bled off
JumpFallvelocity.y >= 0
FallIdle / Runlanded — pick by whether there's input

Two of these hide a real decision:

Run → Idle: on input released, or on speed reaching zero? They differ by about a tenth of a second — the deceleration you built in stage 01. Choose based on what you'd want an animation to do, and write down which and why.

Jump → Fall at velocity.y >= 0: so the variable-jump clamp belongs to Jump, and Fall never needs to know about it. Convenient. Check that it survives the apex hang, where velocity sits near zero and could flip states repeatedly.

D — Decision 1 comes due#

Where does move_and_slide() get called? Every state at the end of its own physics_update, or the player once after the machine has updated?

Both work. Argue it out before choosing:

  • Each state calls it — a state fully owns its frame. But two states that forget and both call it produce double-speed movement, and a bug that only appears in one state is harder to see than one that appears everywhere.
  • The player calls it once — impossible to double up, and there's one place to add cross-cutting work later. Stage 08 adds exactly such a thing.

Record your choice and reason in the architecture document, dated. Stage 08 will tell you whether you'd choose the same again — and if you wouldn't, that's a genuinely useful outcome, not a mistake.

E — Prove nothing changed#

Before starting, write down five things you can measure: jump height in tiles, run speed across the room, coyote frames, and so on. Measure them after. If any moved, you changed behaviour while claiming not to — find it before continuing.

This habit is what makes refactoring safe, and it's most of what "having tests" buys in projects that have them.

git add . && git commit -m "stage 05: node-based state machine, no behaviour change"

Checkpoint — definition of done#

  • Every measurement matches its pre-refactor value
  • player.gd is short enough to read at a glance
  • No state file references another state's script — only names
  • A debug label shows the current state name and it matches what you're doing
  • Decision 1 is recorded with its reason and a date
  • Zero warnings; committed
  • You can explain: why do the coyote and buffer timers live on the player rather than in a state?

Stretch (no instructions)#

Give StateMachine a state_changed(from, to) signal and log every transition with a timestamp. Play for a minute, read the log, and find one transition that happens more often than you expected. There will be one.

If you get stuck#

  • Character does nothing → is initial_state assigned, and is the machine's _physics_process actually forwarding to the current state? Print in both.
  • Movement is double speed → move_and_slide() is being called twice. Decision 1 exists precisely because of this.
  • Flickering between Jump and Fall at the apex → your transition condition sits exactly where the apex hang holds velocity near zero. Either bias the threshold or reconsider what "falling" means.
  • A state can't see tuning → states reach it through the injected player reference. If that's null, re-read the injection section of the systems shelf.
  • Jump lost its variable height → the clamp moved into Jump but the timer still lives on the player, or vice versa. They must be reachable from the same place.

Next: Stage 06 — the dash. The structure you just built is about to pay for itself.