Proposal: builtin Platformer controller — movement feel, tile collision, platforms, score/lives/levels (base + extensible) #58

Closed
opened 2026-09-01 05:13:31 +02:00 by orkun · 2 comments
Owner

Part of the builtin-controllers layer (#57). Conforms to the six-lever extensibility contract defined there. Planning only — no implementation.

Context

The reference controller family, and deliberately the smallest, because a platformer exercises the whole extensibility contract end-to-end: tight input, a feel-critical movement integrator, tile collision, and game-loop scaffolding (score, lives, levels). If the six levers feel good here, they generalize. Modeled on Godot's CharacterBody2D + move_and_slide, Celeste's well-documented movement (coyote time, jump buffering, variable jump height), and the Unity 2D platformer template.

Layered design

Layer 0 — shared foundation (from #57)

Reuses Body { vx, vy, grounded, ... } + Collider { w, h, layer, mask } + esys_move (AABB sweep vs. tile grid, one-way platforms, triggers). The platformer adds movement policy on top of that mechanism; it does not reimplement collision.

Layer 1 — the controller component (data-first, lever 1)

property Platformer {
  move_speed:   int = 6,      # ground max speed (tiles/s, fixed under the hood)
  accel:        int = 60,     # ground acceleration
  air_control:  int = 40,     # 0..100 % of ground accel while airborne
  friction:     int = 50,
  jump_height:  int = 4,      # tiles; gravity+impulse derived from this + apex time
  apex_frames:  int = 14,     # frames to apex → defines gravity (feel, not physics)
  fall_gravity_mul: int = 160,# 100 = same as rise; >100 = snappier fall (Celeste)
  coyote_frames: int = 6,     # grace after leaving ledge
  jump_buffer_frames: int = 6,# grace pressing jump before landing
  max_fall:     int = 18,
  air_jumps:    int = 0,      # 0 = none; 1 = double jump; N = multi
  policy:       int = 0       # movement profile selector (lever 6)
}
model Hero { Pos, Body, Collider, Platformer, SpriteAnim }

Everything that defines "feel" is a defaulted field. A floaty moon-jump vs. a snappy Celeste dash is a spawn-time data change, no code.

Layer 2 — engine-owned sub-systems (each independently disable-able, lever 5)

Decomposed so a dev can replace exactly one:

  • esys_platformer_input (phase Input) — reads the move/jump input actions (not raw keys) into intent fields on Body.
  • esys_platformer_move (phase Update) — accel/friction/air-control → vx; runs before esys_move does the sweep.
  • esys_platformer_gravity (phase Update) — gravity from apex_frames/fall_gravity_mul, terminal clamp.
  • esys_platformer_jump (phase Update) — coyote + buffer + variable height + air-jumps state machine.
  • esys_platformer_anim (phase Update) — maps state (idle/run/rise/fall) to Anim.play clip names.

Reads input actions by convention: "move_x", "jump", "jump_release" — bound via the existing Input.action map, so rebinding/gamepad/replay all come for free.

Layer 3 — events at every decision point (observe + veto, lever 4)

event cancellable JumpRequested { e: int, kind: int }   # kind: 0 ground,1 coyote,2 air
event JumpPerformed  { e: int, kind: int }
event Landed         { e: int, fall_frames: int }        # for landing squash / fall damage
event cancellable WallGrabbed { e: int, dir: int }
event StateChanged   { e: int, from: int, to: int }

Double-jump-only-after-pickup, no-jump-in-water, fall damage, landing dust, "consume stamina to jump" — all are @On listeners, several via cancel. No controller edits.

Layer 4 — game scaffolding (composable, not mandatory)

Small, separable builtin properties + systems a platformer usually wants — each opt-in:

  • Score/Combo: property Score { value, combo, mult } + Score.add(e, n); emits ScoreChanged.
  • Lives/Health: property Life { hp, max, lives, iframes }; Damage/Death events (shared with shooter/RPG via #57 stats).
  • Collectibles: property Pickup { kind, value } + esys_pickup (trigger-overlap → ItemPickedUp event → default adds to Score).
  • Level/Checkpoint: property Checkpoint, property Goal; Level.load(name), Level.restart(), CheckpointReached/LevelComplete events. Levels are data (tilemap + entity list), loadable via the existing scene system.
  • Moving/one-way/crumbling platform blocks: property Platform { kind, path, speed } where kind ∈ {solid, one_way, moving, crumble, fall_through}. esys_platform moves them on a Motion/Tween path; the mover carries riders (standard "parent velocity" resolution).

Layer 5 — hazards & interactions

property Hazard { dmg, knockback }, property Spring { power }, property Ladder, property Conveyor { speed } — all trigger-overlap driven, all emitting events, all data-tuned.

How a developer extends each area (the contract, concretely)

Want to change Lever How
Jump feel 1 data spawn with different jump_height/apex_frames/fall_gravity_mul
Add wall-jump 2 compose + 4 event add own handler in Update @After(esys_platformer_jump), listen WallGrabbed
Double jump gated on power-up 4 event @On(JumpRequested) → cancel if kind==2 and no power-up
Replace the whole movement integrator 5 disable disable system esys_platformer_move, write own handler; keep collision/jump/anim
Different gravity model (e.g. Metroid) 6 policy set Platformer.policy to a shipped profile, or event-drive it
Fall damage 4 event @On(Landed) → apply damage from fall_frames
Score rule 4 event @On(ItemPickedUp)/@On(ScoreChanged)
Custom animation states 2 compose disable system esys_platformer_anim, drive Anim.play yourself

Every row is "no engine fork." That's the acceptance bar.

Determinism / Ludic ties

  • All integer/fixed → replay + lockstep hold; a recorded input tape reproduces a full run (ties to Input.record/replay).
  • Controller state is components + owned vars → world_save/snapshot captures it (checkpoints, rollback netcode for co-op platformers).
  • Uses Grid.* for tile queries, Vector/Rect for math, Motion/Tween for moving platforms.

Phasing

  1. Platformer component + input/move/gravity/jump sub-systems + JumpRequested/Performed/Landed events (the core "it feels good" milestone). Depends on #57 Body/esys_move.
  2. Coyote/buffer/variable-height/air-jumps + StateChanged + anim mapping.
  3. Platform blocks (moving/one-way/crumble) + riders; hazards/springs/ladders/conveyors.
  4. Score/Life/Pickup/Checkpoint/Level scaffolding + level-as-data loading via scenes.
  5. Policy profiles + @Before/@After ordering examples + a complete examples/games/platformer.ludic that a dev can fork.

References

  • Godot CharacterBody2D / move_and_slide, one-way collision, 2D platformer demo.
  • Celeste movement breakdown (coyote time, jump buffer, variable jump, asymmetric gravity).
  • Unity 2D platformer template; "Platformer Toolkit" (GMTK) feel parameters.
_Part of the builtin-controllers layer (#57). Conforms to the six-lever extensibility contract defined there. Planning only — no implementation._ ## Context The reference controller family, and deliberately the smallest, because a platformer exercises the whole extensibility contract end-to-end: tight input, a feel-critical movement integrator, tile collision, and game-loop scaffolding (score, lives, levels). If the six levers feel good here, they generalize. Modeled on Godot's `CharacterBody2D` + `move_and_slide`, Celeste's well-documented movement (coyote time, jump buffering, variable jump height), and the Unity 2D platformer template. ## Layered design ### Layer 0 — shared foundation (from #57) Reuses `Body { vx, vy, grounded, ... }` + `Collider { w, h, layer, mask }` + `esys_move` (AABB sweep vs. tile grid, one-way platforms, triggers). The platformer adds movement *policy* on top of that mechanism; it does not reimplement collision. ### Layer 1 — the controller component (data-first, lever 1) ``` property Platformer { move_speed: int = 6, # ground max speed (tiles/s, fixed under the hood) accel: int = 60, # ground acceleration air_control: int = 40, # 0..100 % of ground accel while airborne friction: int = 50, jump_height: int = 4, # tiles; gravity+impulse derived from this + apex time apex_frames: int = 14, # frames to apex → defines gravity (feel, not physics) fall_gravity_mul: int = 160,# 100 = same as rise; >100 = snappier fall (Celeste) coyote_frames: int = 6, # grace after leaving ledge jump_buffer_frames: int = 6,# grace pressing jump before landing max_fall: int = 18, air_jumps: int = 0, # 0 = none; 1 = double jump; N = multi policy: int = 0 # movement profile selector (lever 6) } model Hero { Pos, Body, Collider, Platformer, SpriteAnim } ``` Everything that defines "feel" is a defaulted field. A floaty moon-jump vs. a snappy Celeste dash is a spawn-time data change, no code. ### Layer 2 — engine-owned sub-systems (each independently disable-able, lever 5) Decomposed so a dev can replace exactly one: - `esys_platformer_input` (phase Input) — reads the `move`/`jump` **input actions** (not raw keys) into intent fields on `Body`. - `esys_platformer_move` (phase Update) — accel/friction/air-control → `vx`; runs *before* `esys_move` does the sweep. - `esys_platformer_gravity` (phase Update) — gravity from `apex_frames`/`fall_gravity_mul`, terminal clamp. - `esys_platformer_jump` (phase Update) — coyote + buffer + variable height + air-jumps state machine. - `esys_platformer_anim` (phase Update) — maps state (idle/run/rise/fall) to `Anim.play` clip names. Reads input actions by convention: `"move_x"`, `"jump"`, `"jump_release"` — bound via the existing `Input.action` map, so rebinding/gamepad/replay all come for free. ### Layer 3 — events at every decision point (observe + veto, lever 4) ``` event cancellable JumpRequested { e: int, kind: int } # kind: 0 ground,1 coyote,2 air event JumpPerformed { e: int, kind: int } event Landed { e: int, fall_frames: int } # for landing squash / fall damage event cancellable WallGrabbed { e: int, dir: int } event StateChanged { e: int, from: int, to: int } ``` Double-jump-only-after-pickup, no-jump-in-water, fall damage, landing dust, "consume stamina to jump" — all are `@On` listeners, several via `cancel`. No controller edits. ### Layer 4 — game scaffolding (composable, not mandatory) Small, separable builtin properties + systems a platformer usually wants — each opt-in: - **Score/Combo**: `property Score { value, combo, mult }` + `Score.add(e, n)`; emits `ScoreChanged`. - **Lives/Health**: `property Life { hp, max, lives, iframes }`; `Damage`/`Death` events (shared with shooter/RPG via #57 stats). - **Collectibles**: `property Pickup { kind, value }` + `esys_pickup` (trigger-overlap → `ItemPickedUp` event → default adds to Score). - **Level/Checkpoint**: `property Checkpoint`, `property Goal`; `Level.load(name)`, `Level.restart()`, `CheckpointReached`/`LevelComplete` events. Levels are data (tilemap + entity list), loadable via the existing scene system. - **Moving/one-way/crumbling platform blocks**: `property Platform { kind, path, speed }` where `kind` ∈ {solid, one_way, moving, crumble, fall_through}. `esys_platform` moves them on a `Motion`/`Tween` path; the mover carries riders (standard "parent velocity" resolution). ### Layer 5 — hazards & interactions `property Hazard { dmg, knockback }`, `property Spring { power }`, `property Ladder`, `property Conveyor { speed }` — all trigger-overlap driven, all emitting events, all data-tuned. ## How a developer extends each area (the contract, concretely) | Want to change | Lever | How | |---|---|---| | Jump feel | 1 data | spawn with different `jump_height`/`apex_frames`/`fall_gravity_mul` | | Add wall-jump | 2 compose + 4 event | add own handler in Update `@After(esys_platformer_jump)`, listen `WallGrabbed` | | Double jump gated on power-up | 4 event | `@On(JumpRequested)` → `cancel` if `kind==2` and no power-up | | Replace the whole movement integrator | 5 disable | `disable system esys_platformer_move`, write own handler; keep collision/jump/anim | | Different gravity model (e.g. Metroid) | 6 policy | set `Platformer.policy` to a shipped profile, or event-drive it | | Fall damage | 4 event | `@On(Landed)` → apply damage from `fall_frames` | | Score rule | 4 event | `@On(ItemPickedUp)`/`@On(ScoreChanged)` | | Custom animation states | 2 compose | `disable system esys_platformer_anim`, drive `Anim.play` yourself | Every row is "no engine fork." That's the acceptance bar. ## Determinism / Ludic ties - All integer/fixed → replay + lockstep hold; a recorded input tape reproduces a full run (ties to `Input.record`/`replay`). - Controller state is components + owned `var`s → `world_save`/snapshot captures it (checkpoints, rollback netcode for co-op platformers). - Uses `Grid.*` for tile queries, `Vector`/`Rect` for math, `Motion`/`Tween` for moving platforms. ## Phasing 1. `Platformer` component + input/move/gravity/jump sub-systems + `JumpRequested/Performed/Landed` events (the core "it feels good" milestone). Depends on #57 `Body`/`esys_move`. 2. Coyote/buffer/variable-height/air-jumps + `StateChanged` + anim mapping. 3. Platform blocks (moving/one-way/crumble) + riders; hazards/springs/ladders/conveyors. 4. Score/Life/Pickup/Checkpoint/Level scaffolding + level-as-data loading via scenes. 5. Policy profiles + `@Before`/`@After` ordering examples + a complete `examples/games/platformer.ludic` that a dev can fork. ## References - Godot `CharacterBody2D` / `move_and_slide`, one-way collision, 2D platformer demo. - Celeste movement breakdown (coyote time, jump buffer, variable jump, asymmetric gravity). - Unity 2D platformer template; "Platformer Toolkit" (GMTK) feel parameters.
Author
Owner

Amendment — ships as an external package

Per the decision on #57, this controller ships as an external, versioned source package (Ludic modules, compiled into the consumer's binary), not as compiler/runtime/native stdlib and not as a precompiled OS binary. Rationale (wasm target, determinism, compile-time ECS) and the binary-mod escape hatch are on #57.

Depends on #62 (package distribution + package-declarable namespaces/components/engine-systems) — the mechanism that lets this register its component schema and engine-owned systems without a compiler edit. The six-lever extensibility contract it conforms to stays in the language core.

## Amendment — ships as an external package Per the decision on #57, this controller ships as an **external, versioned source package** (Ludic modules, compiled into the consumer's binary), **not** as compiler/`runtime/native` stdlib and **not** as a precompiled OS binary. Rationale (wasm target, determinism, compile-time ECS) and the binary-mod escape hatch are on #57. **Depends on #62** (package distribution + package-declarable namespaces/components/engine-systems) — the mechanism that lets this register its component schema and engine-owned systems without a compiler edit. The six-lever extensibility contract it conforms to stays in the language core.
Author
Owner

Shipped — ludic.platformer (commit dd5cb58)

The reference implementation of the six-lever contract (#57), a source package that owns movement policy and reuses the engine Body/Collider + esys_move swept-AABB collision (never re-implements it).

Core controller — every "feel" number is a defaulted POD field (lever 1): move_speed, jump_height, apex_frames, fall_gravity_mul, coyote_frames, jump_buffer_frames, air_jumps, policy. Gravity + jump impulse are derived from height/apex (feel-first), with coyote time, jump buffering, variable jump height and multi-jump.

Decomposed sub-systems (lever 5 — each disable system-able):

  • esys_platformer_input (Input) — action map → intent fields
  • esys_platformer_move / esys_platformer_gravity / esys_platformer_jump (FixedUpdate, before the sweep)
  • esys_platformer_anim (LateUpdate, after collision) — derives state, emits events

Events at every decision (lever 4): cancellable JumpRequested, JumpPerformed, Landed, StateChanged. Gravity policy enum (lever 6): asymmetric / symmetric / floaty.

Opt-in scaffolding (scaffolding.ludic): moving & crumbling platform blocks with rider carry, collectibles + Score, springs, hazards + a light Life/i-frames model, and checkpoint/goal triggers.

Every row of the issue's extension table is reachable with no engine fork — e.g. "double jump gated on a power-up" is a two-line @On(JumpRequested) { cancel }, and "replace the whole movement integrator" is disable system esys_platformer_move + your own handler.

Deterministic integer Q16.16 throughout. Examples examples/games/platformer_demo.ludic (8 checks: gravity/land, apex height, coyote, veto-gated double jump, wall collision, disable system) and examples/games/platformer_scaffolding.ludic (6 checks) — both regression cases in x test. Also fixed a latent codegen bug (an SSA register name collided once a program declared ≥11 events). Docs: docs/CONTROLLERS.md.

Closing as done.

## Shipped — `ludic.platformer` (commit dd5cb58) The reference implementation of the six-lever contract (#57), a source package that owns movement *policy* and reuses the engine `Body`/`Collider` + `esys_move` swept-AABB collision (never re-implements it). **Core controller** — every "feel" number is a defaulted POD field (lever 1): `move_speed`, `jump_height`, `apex_frames`, `fall_gravity_mul`, `coyote_frames`, `jump_buffer_frames`, `air_jumps`, `policy`. Gravity + jump impulse are *derived* from height/apex (feel-first), with coyote time, jump buffering, variable jump height and multi-jump. **Decomposed sub-systems** (lever 5 — each `disable system`-able): - `esys_platformer_input` (Input) — action map → intent fields - `esys_platformer_move` / `esys_platformer_gravity` / `esys_platformer_jump` (FixedUpdate, before the sweep) - `esys_platformer_anim` (LateUpdate, after collision) — derives state, emits events **Events at every decision** (lever 4): `cancellable JumpRequested`, `JumpPerformed`, `Landed`, `StateChanged`. **Gravity policy enum** (lever 6): asymmetric / symmetric / floaty. **Opt-in scaffolding** (`scaffolding.ludic`): moving & crumbling platform blocks with rider carry, collectibles + `Score`, springs, hazards + a light `Life`/i-frames model, and checkpoint/goal triggers. Every row of the issue's extension table is reachable with no engine fork — e.g. "double jump gated on a power-up" is a two-line `@On(JumpRequested) { cancel }`, and "replace the whole movement integrator" is `disable system esys_platformer_move` + your own handler. Deterministic integer Q16.16 throughout. Examples `examples/games/platformer_demo.ludic` (8 checks: gravity/land, apex height, coyote, veto-gated double jump, wall collision, `disable system`) and `examples/games/platformer_scaffolding.ludic` (6 checks) — both regression cases in `x test`. Also fixed a latent codegen bug (an SSA register name collided once a program declared ≥11 events). Docs: `docs/CONTROLLERS.md`. Closing as done.
orkun closed this issue 2026-09-01 16:28:42 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: workshopsoft/ludic#58
No description provided.