doc 2 of 10

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#

SystemPatternWhy
Player movementCharacterBody2D + 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 tuningMovementTuning extends Resource, @exported onto the playerEvery constant in one Inspector panel, tweakable while the game runs. Feel is found by iteration, and recompiling to try a number kills iteration
Input readingCoyote and buffer timers held on the player, not in any stateThey're timing concerns that outlive individual states — refilled while grounded, consulted while airborne. Anything true in most states belongs to the body
Level geometryTileMapLayer (one for collision, one for decoration)The 4.3+ node. Collision comes from the TileSet's physics layer, not from hand-placed bodies
Room framingCamera2D with limits per roomCeleste-style rooms are screen-sized; limits do the work with no code
HazardsArea2D in a hazards group, signalling up to the roomA spike doesn't know what a player is; the room decides that touching one means respawn
RespawnRoom holds the spawn point and re-places the playerNo death animation state, no scene reload — instant retry is a genre requirement
CollectiblesArea2D signalling up to the room, which countsOne-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.

LayerNameContents
1solidTerrain from the Solids TileMapLayer
2playerThe player body
3hazardSpike and pit Area2Ds
4collectiblePickups

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 .tres gives 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.

SignalEmitterListeners
finished(next_state)each State nodeStateMachine
diedPlayerRoom (respawns)
collected(kind)a collectible Area2DRoom (counts)
room_completedRoomGame (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.

  1. 01 — run + gravity + a floor. The feel of acceleration, before anything else exists.
  2. 02 — the jump, in its three parts.
  3. 03 — coyote time and input buffering. Nothing looks different; everything feels fairer.
  4. 04 — a real room: tilemap, camera, spikes, respawn.
  5. 05 — restructure into the state machine. No new behaviour, and that's the discipline.
  6. 06 — the dash.
  7. 07 — walls.
  8. 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.