Proposal: builtin Platformer controller — movement feel, tile collision, platforms, score/lives/levels (base + extensible) #58
Labels
No labels
area:ci
area:docs
area:input
area:net
area:rendering
area:repo
area:stdlib
area:tooling
area:types
cleanup
dx
priority:high
priority:low
priority:medium
proposal
status:in-progress
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: workshopsoft/ludic#58
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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)
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 themove/jumpinput actions (not raw keys) into intent fields onBody.esys_platformer_move(phase Update) — accel/friction/air-control →vx; runs beforeesys_movedoes the sweep.esys_platformer_gravity(phase Update) — gravity fromapex_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) toAnim.playclip names.Reads input actions by convention:
"move_x","jump","jump_release"— bound via the existingInput.actionmap, so rebinding/gamepad/replay all come for free.Layer 3 — events at every decision point (observe + veto, lever 4)
Double-jump-only-after-pickup, no-jump-in-water, fall damage, landing dust, "consume stamina to jump" — all are
@Onlisteners, several viacancel. No controller edits.Layer 4 — game scaffolding (composable, not mandatory)
Small, separable builtin properties + systems a platformer usually wants — each opt-in:
property Score { value, combo, mult }+Score.add(e, n); emitsScoreChanged.property Life { hp, max, lives, iframes };Damage/Deathevents (shared with shooter/RPG via #57 stats).property Pickup { kind, value }+esys_pickup(trigger-overlap →ItemPickedUpevent → default adds to Score).property Checkpoint,property Goal;Level.load(name),Level.restart(),CheckpointReached/LevelCompleteevents. Levels are data (tilemap + entity list), loadable via the existing scene system.property Platform { kind, path, speed }wherekind∈ {solid, one_way, moving, crumble, fall_through}.esys_platformmoves them on aMotion/Tweenpath; 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)
jump_height/apex_frames/fall_gravity_mul@After(esys_platformer_jump), listenWallGrabbed@On(JumpRequested)→cancelifkind==2and no power-updisable system esys_platformer_move, write own handler; keep collision/jump/animPlatformer.policyto a shipped profile, or event-drive it@On(Landed)→ apply damage fromfall_frames@On(ItemPickedUp)/@On(ScoreChanged)disable system esys_platformer_anim, driveAnim.playyourselfEvery row is "no engine fork." That's the acceptance bar.
Determinism / Ludic ties
Input.record/replay).vars →world_save/snapshot captures it (checkpoints, rollback netcode for co-op platformers).Grid.*for tile queries,Vector/Rectfor math,Motion/Tweenfor moving platforms.Phasing
Platformercomponent + input/move/gravity/jump sub-systems +JumpRequested/Performed/Landedevents (the core "it feels good" milestone). Depends on #57Body/esys_move.StateChanged+ anim mapping.@Before/@Afterordering examples + a completeexamples/games/platformer.ludicthat a dev can fork.References
CharacterBody2D/move_and_slide, one-way collision, 2D platformer demo.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/nativestdlib 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.
Shipped —
ludic.platformer(commitdd5cb58)The reference implementation of the six-lever contract (#57), a source package that owns movement policy and reuses the engine
Body/Collider+esys_moveswept-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 fieldsesys_platformer_move/esys_platformer_gravity/esys_platformer_jump(FixedUpdate, before the sweep)esys_platformer_anim(LateUpdate, after collision) — derives state, emits eventsEvents 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 lightLife/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" isdisable 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) andexamples/games/platformer_scaffolding.ludic(6 checks) — both regression cases inx 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.