← All decisions

Build the platformer's pure game logic (glemy/games/platformer), confirming a third distinct wall/landing policy and a real Landed-event design bug found by direct testing

architecturethird-gameplatformercore-api

Context

RM-034 Phase 1: glemy's third reference game's own Model/tick, built on top of Phase 0's promoted physics/rect detection math (decision 0058). The headline question this whole game was chosen to answer: does any single wall/landing restitution policy generalize across all three games. Tiers damps to ~10% (physics/bounds.bounce, decision 0029), Breakout reflects near-elastically (bounce_off_walls, decision 0052) -- a platformer needs a third, genuinely distinct policy: landing on a platform or hitting a side wall must stop the player dead (zero restitution), not bounce at all. While implementing the Landed GameEvent (fired when the player transitions from falling to resting on a platform), direct test design surfaced a real logic bug before it shipped: the first-draft implementation decided 'was this a landing' by checking whether the player's velocity.y was negative at the instant of collision resolution -- but under continuous gravity integration, a player resting motionless on a platform still picks up a tiny negative vy every single tick (gravity acts for one tick before the very next tick's collision resolution catches and re-zeroes it), so that check would have fired Landed on every tick a player stood still, not just the real landing transition.

Options considered

Detect a landing by checking whether velocity.y was negative immediately before collision resolution zeroed it — rejected
The first-draft implementation. Caught by direct test design (working through the exact frame-by-frame trace of a resting player under continuous gravity integration) before any test was even run: a resting player's velocity.y is always slightly negative by the time collision resolution runs each tick (gravity had one tick to act on it since the previous tick's resolution zeroed it), making this check indistinguishable between 'just landed' and 'has been resting for 100 ticks'. Confirmed by reasoning through the exact numeric trace, not by a failing test discovered after the fact.
Detect a landing via a grounded state *transition* (this tick's grounded is True, last tick's (model.grounded, the incoming field) was False) — chosen
Robust against the exact failure mode above: `model.grounded` only reads True once collision resolution has already stabilized a resting player at zero velocity, so a genuine landing is unambiguously the tick where grounded flips from False to True, independent of any single tick's transient velocity noise from one tick of gravity. resolve_platforms/resolve_platform_hit were simplified to return #(Entity, Bool) (player, grounded) instead of a three-element tuple, with `tick` itself deriving `landed = grounded && !model.grounded` -- a cleaner split of responsibility (the platform-resolution functions only ever report the current physical state, not an event) that happened to also be the fix.

Decision

Added glemy/games/platformer.gleam: GameStatus (Playing/Won/Lost, same sum-type reasoning as Breakout's), Model (player: Entity, bounds, platforms: List(Rect), goal: Rect, grounded: Bool, status), GameEvent (Jumped/Landed/Fell), Input (move_left/move_right/jump), tick, new, colored_player. tick composes entity.integrate directly (bypassing physics.update, same reasoning as Breakout's own tick) with three bespoke, zero-restitution resolution functions: stop_at_walls (side walls and a soft ceiling stop the player dead, the bottom edge is left untouched entirely -- same 'falling past bounds.min.y is the lose trigger, not a wall' convention as Breakout), resolve_platforms/resolve_platform_hit (resolves at most one overlapping platform per frame, same simplification as brick.resolve_first_hit -- landing snaps position and zeroes vertical velocity and sets grounded, hitting the underside stops upward velocity only, hitting a side pushes back to the nearer edge and zeroes horizontal velocity). Jump is gated on the *incoming* model.grounded (read before this tick's own motion can overwrite it), not a same-tick freshly-computed value -- a deliberate MVP simplification (no same-tick land-then-jump chaining) noted as a future tuning candidate if it feels wrong in play. A small, fixed six-platform staircase level plus a goal rectangle, MVP-scoped identically to Breakout's own single-life, no-lives-counter precedent: no score, no hazards beyond falling off the bottom, no coyote-time/variable-jump-height/one-way-platforms.

Verification

gleam test and gleam test --target javascript both pass -- 281 Erlang / 306 JavaScript, exactly the prior baseline (263/288 as of decision 0058) plus 18 new tests, all pure Gleam with zero FFI dependency, so both counts grew identically. The Landed-event bug was caught and fixed before any test ran (by reasoning through the exact frame trace during test-case design), then a dedicated regression test (tick_does_not_refire_landed_while_already_resting_test) was written specifically to pin the corrected behavior -- a resting player integrated through one more real (nonzero-dt) tick produces zero events, not [Landed]. deno task check-warnings passes with the baseline completely unchanged (76/76, zero new warnings) -- nothing in this phase touches @target(javascript)-gated code.

Consequences

glemy/games/platformer is now a complete, independently-tested game engine's worth of pure logic with no rendering or FFI wiring yet, mirroring Breakout's own Phase 1 checkpoint -- Phase 2 (rendering/input wiring) has a fully verified foundation to build on. The wall/landing-policy negative result is now backed by three independent data points instead of one: physics.update's composition remains correctly not a drop-in generic physics step for any game with non-default wall behavior, exactly as decisions 0052/0058 already concluded, now demonstrated a third distinct way (zero restitution, not just a different degree of bounce). The grounded-state-transition pattern for detecting a discrete physical event from continuous simulation state (rather than trusting a single frame's transient derivative) is a real, reusable technique a future game's own tick function should reach for first, not rediscover, if it needs a similar one-shot event derived from ongoing physics.

References