Precision Platformer — Architecture
The design map for a Celeste-style movement platformer: what the systems are, how they talk, and which choices are deliberately left to you.
Read this before stage 01, and again before stage 05 — the second read will make far more sense than the first, which is normal and is the point of writing it down.
The design centre#
Almost every game has a player controller. In this genre the player controller is the game. There is no combat, no inventory, no dialogue to hide behind — if the jump feels wrong, nothing else can save it. So the architecture is unusually lopsided on purpose: one entity gets an elaborate structure and everything else stays deliberately plain.
That lopsidedness is itself the lesson. Architecture should follow where the complexity actually is, not distribute itself evenly for tidiness.
Systems map#
| System | Pattern | Why |
|---|---|---|
| Player movement | CharacterBody2D + node-based state machine (one node per state) | Six-plus states each with real per-state logic; an enum would work until the dash arrives and then stop being readable |
| Movement tuning | MovementTuning extends Resource, @exported onto the player | Every constant in one Inspector panel, tweakable while the game runs. Feel is found by iteration, and recompiling to try a number kills iteration |
| Input reading | Coyote and buffer timers held on the player, not in any state | They're timing concerns that outlive individual states — refilled while grounded, consulted while airborne. Anything true in most states belongs to the body |
| Level geometry | TileMapLayer (one for collision, one for decoration) | The 4.3+ node. Collision comes from the TileSet's physics layer, not from hand-placed bodies |
| Room framing | Camera2D with limits per room | Celeste-style rooms are screen-sized; limits do the work with no code |
| Hazards | Area2D in a hazards group, signalling up to the room | A spike doesn't know what a player is; the room decides that touching one means respawn |
| Respawn | Room holds the spawn point and re-places the player | No death animation state, no scene reload — instant retry is a genre requirement |
| Collectibles | Area2D signalling up to the room, which counts | One-to-one relationship; an event bus here would be pure overhead |
Notice the last row. This project has no event bus and no autoloads, and that's a design statement rather than an omission — a platformer lives almost entirely in one branch of the tree, so the parent-child channels are enough. If you find yourself wanting a global, that's worth a note in this file rather than a quiet addition.
Scene tree#
Game (Node2D)
├── Room (Node2D) ← one per level; swapped wholesale
│ ├── Solids (TileMapLayer) ← collision layer 1
│ ├── Decoration (TileMapLayer) ← no physics
│ ├── Hazards (Node2D) ← Area2D children, group "hazards"
│ ├── Collectibles (Node2D)
│ └── SpawnPoint (Marker2D)
└── Player (CharacterBody2D)
├── Sprite2D
├── CollisionShape2D ← rectangle; capsules make wall detection lie
├── Camera2D ← child, so following needs no code; limits frame the room
└── StateMachine (Node)
├── Idle, Run, Jump, Fall
├── Dash
└── WallSlide, WallClimb
Player sits beside Room, not inside it, so swapping rooms doesn't destroy and recreate the
player mid-motion.
Collision layers#
Assign these in Project Settings → Layer Names → 2D Physics on day one. Unnamed numeric layers become unreadable within a week.
| Layer | Name | Contents |
|---|---|---|
| 1 | solid | Terrain from the Solids TileMapLayer |
| 2 | player | The player body |
| 3 | hazard | Spike and pit Area2Ds |
| 4 | collectible | Pickups |
The player is on layer 2 and masks layer 1 — that's what makes it collide with terrain and nothing else.
Detection runs the other way round, and this is the part that trips everyone: an Area2D
notices a body when the Area's mask contains the body's layer. So the spike sits on
layer 3 and masks layer 2, and that is what fires body_entered — not anything on the
player. Giving the player a mask bit for layer 3 would do nothing useful; it would just try to
collide with spikes. Areas here are pure sensors and never push anything.
Data model#
The interesting decision in this project is that the character's feel is data, not code.
MovementTuning (Resource)
├── run: max_speed, run_accel, run_reduce, air_multiplier
├── fall: gravity, max_fall, fast_fall_speed, half_gravity_threshold
├── jump: jump_velocity, variable_jump_time, jump_h_boost
├── assist: coyote_time, jump_buffer_time, corner_correction_pixels
├── dash: dash_speed, dash_time, dash_cooldown, end_dash_speed, dash_freeze_time
└── wall: wall_jump_h_speed, wall_slide_start_max, wall_slide_time, climb_max_stamina
One .tres, @exported onto the player, read by every state. Three things fall out of that:
- You can tune the character while the game is running and feel the change immediately.
- A second
.tresgives you a variant character — a heavier one, an assist mode — with no code. - The numbers stop being scattered magic constants across seven state files.
Runtime state (velocity, dashes_left, stamina, timers) lives on the player, never on the
tuning resource. The definition-versus-state split is
the same one the inventory shelf teaches, and the same trap applies: a loaded .tres is a
shared, cached instance, so writing to it changes the tuning for every holder for the rest
of the session. It won't corrupt the file on disk — saving takes an explicit
ResourceSaver.save(), and res:// is read-only in an exported game anyway — which is exactly
what makes the bug slippery. It looks fine on disk and behaves wrongly in play. If you ever need
a per-instance variant, duplicate() it.
Event map#
Small on purpose.
| Signal | Emitter | Listeners |
|---|---|---|
finished(next_state) | each State node | StateMachine |
died | Player | Room (respawns) |
collected(kind) | a collectible Area2D | Room (counts) |
room_completed | Room | Game (loads the next) |
Every one is past tense and every one travels exactly one step up the tree. If a fifth signal appears that needs to jump branches, that's the moment to read the event bus shelf — and to be suspicious.
Save plan#
Deferred, and honestly so. The only durable state worth writing is the furthest room reached
and per-room best times — both trivially small. Nothing in this architecture blocks it: room
progress is already a value on Game rather than an implicit consequence of which scene is
loaded, which is the property that makes saving easy later.
Decision points#
These are genuinely open. Pick with a reason, write the reason here with a date, and don't revisit it without a new reason.
Decision 1 — Where does move_and_slide() get called? (decide at stage 05)
Every state calls it at the end of its own physics_update, or the player calls it once after
the machine updates.
A: states call it — each state fully owns its frame; easy to give one state unusual movement.
B: the player calls it — impossible to call twice by accident, one place to add global
concerns like corner correction.
This one has a right answer for this project, and stage 08 will make it obvious which. Decide
before then anyway, so you feel the difference.
Decision 2 — Is the dash a state, or a modifier? (decide at stage 06)
A: a Dash state — clean, matches everything else, but "dash out of a wall slide and land in
a run" needs the dash state to know all the states it can exit into.
B: a dash timer the other states consult — no new state, but every state grows a dash
branch, which is the flag problem the machine exists to avoid.
Celeste itself uses a state. That's evidence, not proof.
Decision 3 — Fixed rooms or a scrolling level? (decide now) A: one screen per room, camera locked, instant respawn — the Celeste model, and much simpler camera code. B: a scrolling level with a following camera — more flexible, but then respawn points, checkpoints, and camera framing all become their own problems. This one is decide-now because stage 04 builds whichever you choose, and switching later means rebuilding the level layout.
Decision 4 — Do you keep climbing? (decide at stage 07) Celeste's stamina-based climbing is a large system — grab, climb up and down, stamina drain, the exhausted state, climb jumps. Wall sliding and jumping alone is maybe a third of the work and covers most of what wall movement gives you. Dropping climbing is a legitimate scope choice, not a failure. Decide before you start stage 07, not halfway through it.
Build order#
The walking skeleton is stage 01 alone: a box that runs and falls with weight, on a floor. Everything after that is a layer on something you can already feel.
- 01 — run + gravity + a floor. The feel of acceleration, before anything else exists.
- 02 — the jump, in its three parts.
- 03 — coyote time and input buffering. Nothing looks different; everything feels fairer.
- 04 — a real room: tilemap, camera, spikes, respawn.
- 05 — restructure into the state machine. No new behaviour, and that's the discipline.
- 06 — the dash.
- 07 — walls.
- 08 — corner correction, then prove the whole thing is yours.
Deferred to later modules, listed so the omission is explicit rather than forgotten: animation and particles, audio, a pause menu, save/load, moving platforms, and any second entity type.
A document like this is worth writing before the code, and worth writing yourself. Use this one as the model for your next project's — including the parts where it states a decision and hands it back to you. Those decision points are real: they have consequences, and there is no hidden answer key.