← All decisions

Build Breakout's pure game logic (glemy/games/breakout, rect/brick/paddle) with rectangles kept entirely outside physics, and confirm physics.update does not generalize to a second genre

architecturesecond-gamebreakoutcore-api

Context

Phase 1 of glemy's second reference game (a Breakout/Arkanoid-style paddle-and-brick game, chosen specifically to stress-test the genuinely genre-agnostic claims established across decisions 0047-0050 with a real second, genre-distinct consumer). The user confirmed the scope explicitly: rectangles (the paddle, bricks) stay entirely inside glemy/games/breakout, never becoming physics.Model entities and never touching physics/entity.gleam/collision.gleam/collision_sweep.gleam/render.gleam -- consistent with this project's repeated 'don't generalize Core from one caller' discipline. Building the ball's own motion surfaced a real, previously-unverified limit in the existing Core API: physics/bounds.bounce's restitution ramp (decision 0029, ~10% rebound at genuine impact speed) is tuned specifically for Tiers' fruit to 'dead-plop', not a generically reusable elastic bounce -- reusing physics.update for the ball as originally assumed would have made it lose ~90% of its speed on the first wall hit.

Options considered

Add a restitution/elasticity parameter to physics.update or physics/bounds.bounce so a second game can opt out of Tiers' tuning — rejected
Would be generalizing a Core function's signature based on exactly one second caller's need, the same premature-generalization mistake this project has consistently avoided elsewhere (PreviewQueue, bounds.y_from_top, decision 0049). physics/entity.integrate and physics/collision_sweep.resolve_all_collisions were checked directly and confirmed to have no baked-in tuning of their own -- only the specific composition physics.update represents is Suika-specific. The correct response, per established discipline, is to route around the non-generic composition by calling the genuinely generic primitives directly, not to parameterize Core.
Represent bricks and the paddle as circular physics.Model entities (approximating rectangles as circles) so the existing collision_sweep machinery could be reused directly — rejected
Explicitly decided against by the user before this phase began: this would produce a genre-inauthentic visual (a grid of circles, not brick rectangles) and wouldn't actually exercise new collision math at all, just reuse the same circle-circle math Tiers already validates. A dedicated circle-vs-rectangle overlap primitive (glemy/games/breakout/rect.gleam), scoped to this game only, was built instead.

Decision

Built glemy/games/breakout/rect.gleam (a plain Rect type plus the standard clamp-to-nearest-point circle-vs-AABB overlap test and penetration-axis detection), glemy/games/breakout/brick.gleam (brick grid layout, per-brick score, reflect, and resolve_first_hit -- resolving at most one brick collision per frame, matching physics/collision_sweep.resolve_target_against_rest's own 'resolve one interaction and move on' philosophy), glemy/games/breakout/paddle.gleam (held-key movement reusing physics/bounds.clamp_x directly via the half-width-as-radius equivalence, and a trig-free hit-position-dependent bounce reflection that preserves total ball speed), and glemy/games/breakout.gleam (the hub: Model/GameStatus/GameEvent/Input/tick/new). tick composes physics/entity.integrate and physics/collision_sweep.resolve_all_collisions directly, with a bespoke bounce_off_walls function (near-full-elastic on left/right/top, nothing at the bottom -- what makes falling past bounds.min.y a lose trigger rather than a wall) instead of calling physics.update. GameStatus is a three-way sum type (Playing/Won/Lost), not two independent Bools, ruling out the nonsensical 'both true' state Gleam's type system can reject for free.

Verification

gleam test and gleam test --target javascript both pass -- 253 Erlang / 276 JavaScript, exactly the prior baseline (195/218 as of decision 0051) plus 58 new tests across rect_test/brick_test/paddle_test/breakout_test.gleam, all pure Gleam with no FFI dependency, so both counts grew identically. deno task check-warnings passes with the baseline completely unchanged (51/51, zero new warnings) -- nothing in this phase touches @target(javascript)-gated code. deno task browser-check was not re-run for this phase: nothing here touches glemy/game-shaped rendering or real-browser behavior yet (only pure game logic, no runner wiring), matching CLAUDE.md's own scoping for when that check is required -- it will run again once Phase 2 wires this up to real rendering and input.

Consequences

glemy/games/breakout is now a complete, independently-tested game engine's worth of pure logic with no rendering or FFI wiring yet -- the next phase (rendering the ball via the existing WebGPU pipeline, the paddle/bricks via a CSS-div overlay, and wiring real keyboard input) has a fully verified foundation to build on. The physics.update negative result is now a documented, evidenced fact about this project's Core API rather than an assumption: a future third game's own tick function should expect to compose physics/entity.integrate and physics/collision_sweep.resolve_all_collisions directly if it needs anything other than Tiers' specific restitution tuning, not assume physics.update is a drop-in generic physics step.

References