← All decisions

Decompose wall-handling into caller-parameterized Core primitives (resolve_axis/resolve_high_only), parameterize physics.update's wall step, instead of leaving it unused by 2 of 3 games

architecturecore-apiphysics-updaterefactor

Context

physics.update was used by only 1 of glemy's 3 reference games: its wall-handling step was hardcoded to bounds.bounce, whose restitution tuning is Tiers-specific (decision 0029), so Breakout and Platformer each hand-composed entity.integrate + collision_sweep.resolve_all_collisions plus their own bespoke wall function instead of calling update at all (decisions 0052/0059). Asked directly whether the fix should be to retire update now that most games bypass it, versus restructure it into composable sub-functions Core still owns so capability keeps growing while each game's own code gets simpler -- the latter was chosen, mirroring the exact caller-supplied-callback generalization collision_sweep's interact already established (decision 0047) for the pairwise-collision half of the same composition, now applied to the wall-handling half too. Direct comparison of the three games' actual wall-handling code (not guessed) found the real, already-independently-duplicated shape to extract: bounds.gleam's private bounce_axis, games/breakout.gleam's bounce_axis_full, and games/platformer.gleam's stop_axis were byte-for-byte identical in structure, differing only in the velocity transform applied on each edge; games/breakout.gleam's bounce_top_only and games/platformer.gleam's stop_at_ceiling matched a second, smaller shared shape (max-edge-only).

Options considered

Retire physics.update, treating 'compose the primitives yourself' as the established, correct pattern now that 2 of 3 games already do exactly that — rejected
Conflates two separate problems: update wasn't unused because composition itself is bad, it was unused because one specific step (the hardcoded wall call) couldn't be swapped -- the two genuinely generic steps it composes (entity.integrate, collision_sweep.resolve_all_collisions) are exactly what Breakout/Platformer already reuse directly. Removing update would delete a real, working convenience for any future List(Entity)-shaped game like Tiers, to solve a problem (one hardcoded call) that has a much smaller, targeted fix.
Force Breakout and Platformer to call physics.update directly once its wall step is pluggable, unifying all three games onto one top-level composition function — rejected
Checked directly against both games' actual Model shape: games/tiers.Model wraps a physics.Model (a List(Entity)); games/breakout.Model/games/platformer.Model hold a bare ball/player: Entity field, not a physics.Model. Routing either through update would mean wrapping a single entity in Model(entities: [x], ...) and unwrapping the result again -- more ceremony than their current direct 3-line composition, not less. The Entity-vs-List(Entity) Model-shape difference is real architecture, not an oversight to paper over by forcing a shared entry point neither game's own state actually fits.
Also extract a shared rect.find_first_overlap search helper (games/platformer's own private helper vs. half of brick.resolve_first_hit's traversal) while decomposing this — rejected
brick.resolve_first_hit interleaves search and list-rebuild-on-removal in one recursive pass; decomposing it to share just the search half would need re-matching the found item by Rect equality instead of list position afterward -- a real, if minor, fragility regression (two structurally-identical bricks could theoretically match the wrong one) for no clear benefit, and only Platformer would actually adopt the extracted version. Doesn't clear the 'two real, clean adopters' bar resolve_axis/resolve_high_only both do. Left as two independently correct, small private functions -- recorded here, not silently dropped.

Decision

Added physics/bounds.resolve_axis(position, velocity, min, max, on_low, on_high) and resolve_high_only(position, velocity, max, on_high), both taking the velocity transform as a caller-supplied fn(Float) -> Float. bounds.bounce, games/breakout.bounce_off_walls, and games/platformer.stop_at_walls all refactored to build on these instead of their own private near-duplicate clamp-and-branch functions (bounce_axis/bounce_axis_full/stop_axis/bounce_top_only/stop_at_ceiling all deleted), each now supplying only its own specific velocity transform as a closure -- zero behavior change, confirmed by every existing wall-behavior test passing completely unchanged. physics.update gained a wall_behavior: fn(Entity, Bounds) -> Entity parameter instead of hardcoding bounds.bounce; games/tiers.gleam's one call site now passes bounds.bounce explicitly (same behavior, now visible rather than implicit). Breakout/Platformer deliberately do not switch to calling update itself, for the Entity-vs-List(Entity) Model-shape reason above -- they already benefit from the same resolve_axis/resolve_high_only primitives update itself is now also built from, without adopting update's own List(Entity) orchestration.

Verification

gleam test and gleam test --target javascript both pass -- 288 Erlang / 315 JavaScript, exactly the prior baseline (281/308 as of decision 0061) plus 7 new resolve_axis/resolve_high_only tests (target-agnostic, so both counts grew identically), with every pre-existing wall-behavior assertion in bounds_test.gleam/breakout_test.gleam/platformer_test.gleam/physics_test.gleam/tiers_test.gleam passing completely unchanged -- the actual regression proof that the extraction preserved every game's exact original transform. deno task check-warnings passes with the baseline completely unchanged (104/104, zero new warnings). All three real browser-checks (browser-check/browser-check-breakout/browser-check-platformer) re-run and pass for real, confirming every game's actual wall/landing behavior in a live browser is unaffected.

Consequences

physics.update is now usable, in principle, by any future List(Entity)-shaped game with its own wall policy -- the actual reason it couldn't be reused past Tiers is fixed, not routed around. resolve_axis/resolve_high_only are genuinely reusable Core primitives with three and two real, already-migrated callers respectively, not speculative additions. A future game needing yet another wall/landing policy (a one-way platform, a portal, a sticky wall) supplies its own on_low/on_high closures to the same two functions rather than reimplementing the clamp-and-branch shape a fourth time. The Entity-vs-List(Entity) Model-shape distinction between Tiers and Breakout/Platformer is now explicit, documented architecture (in update's own doc comment) rather than an implicit fact a future reader would have to rediscover by reading three games' source.

References