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:
- What does
_var_jump_timerdo while above zero, and what ends it early? - From stage 03: name the two timers and say which is refilled every grounded frame.
- 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.
| From | To | When |
|---|---|---|
| Idle | Run | horizontal input is non-zero |
| Idle / Run | Jump | a buffered jump and coyote time remain |
| Idle / Run | Fall | not on floor |
| Run | Idle | input is zero and horizontal speed has bled off |
| Jump | Fall | velocity.y >= 0 |
| Fall | Idle / Run | landed — 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 ownphysics_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.gdis 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_stateassigned, and is the machine's_physics_processactually 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
Jumpbut 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.