← All decisions

Wire Breakout's rendering/input (glemy/game_breakout), redesign Brick with a stable id for DOM reconciliation, and extract shared browser-check bootstrap at its second real caller

architecturesecond-gamebreakoutrenderingbrowser-check

Context

Phase 2 of glemy's second reference game: wiring Breakout's already-verified pure logic (decision 0052) to real rendering and input. Only the ball is a real physics.Entity (decision 0052), so the paddle and bricks needed a non-WebGPU rendering path -- CSS-div overlays, the same category of technique glemy/game_tiers_ffi.mjs's setDangerLinePosition already uses for one static overlay, now applied per-frame (the paddle) and event-driven (bricks). Building the event-driven brick-hiding mechanism surfaced a real design gap in decision 0052's Brick/GameEvent shapes: brick.resolve_first_hit returned bricks purely as a List(Rect)-shaped tuple with no stable identifier, and GameEvent.BrickDestroyed carried no payload -- a renderer reacting to that event would have nothing but list position to key a DOM element by, and list position isn't stable across mutation (destroying an earlier brick shifts every later brick's index, the same 'don't use array index as key' problem React's own docs warn about). Separately, this phase needed its own real-browser verification script (per this project's established practice of gating any glemy/game-shaped rendering/input change on a real Chromium run, not just gleam test), and writing it revealed that tools/browser_check.ts's own header comment -- 'a second game gets its own equivalent check ... not this one extended' -- was right about the game-specific assertions, but the Chromium-locating/dev-server-spawning/GPU-launch-flag bootstrap around them was identical, byte for byte, between the two scripts.

Options considered

Key BrickDestroyed and DOM brick elements by list position instead of adding a Brick.id field — rejected
Would work only as long as at most one brick is ever destroyed per game, which is already false by design (brick.resolve_first_hit is called once per frame for the whole game's duration) -- destroying an earlier brick would silently shift every later brick's list index, causing hideBrickElement to retire the wrong DOM element on the very next hit. Confirmed this was a real, not hypothetical, bug before it shipped, not discovered by observed failure.
Re-walk the full remaining brick list every frame and diff it against the DOM instead of an id-keyed event — rejected
Every existing FFI write in this project (setScoreText, setPaddleBox, setDangerLinePosition, etc.) is a targeted 'set this one element' call, not a list-reconciliation pass -- introducing the latter for bricks alone would be a new, heavier category of FFI machinery for a problem a stable id resolves in one field. Matches this phase's own create-once-plus-event-driven-hide design instead: bricks are static and only ever destroyed, never added or moved, so there's nothing to diff.
Extend tools/browser_check.ts in place to also drive breakout.html, rather than writing a second script — rejected
Directly contradicted by that file's own pre-existing header comment, and for the same reason: Tiers and Breakout have entirely incompatible Model/Input/GameEvent shapes and DOM structures, so a single script would need its own internal branch on which game it's checking -- the same Shell-shouldn't-dispatch-on-game-identity reasoning decision 0051 already applied to game_tiers.gleam vs. a hypothetical parameterized runner.
Leave the Chromium-locating/dev-server/launch-flag boilerplate duplicated across two independent browser-check scripts — rejected
That boilerplate (findChromium, fail, waitForServer, the dev-server Deno.Command spawn, the exact headless-Chromium WebGPU flag set from decisions 0011/0012) is identical between the two scripts and carries zero game-specific knowledge -- unlike the game-specific assertions themselves, which are correctly NOT shared. This project's own standing rule is to extract at the *second* real caller, not before one exists; that caller now exists, so the extraction is the rule being followed, not a deviation from it.

Decision

Added `id: Int` to games/breakout/brick.Brick, assigned once at grid-creation time (row * columns + column) and never reused; changed brick.resolve_first_hit's return type from an untagged tuple to a named `Hit(ball, remaining, score, destroyed_id)` record; changed `GameEvent.BrickDestroyed` to carry that `id`. Built glemy/game_breakout.gleam (format_score/x_fraction/rect_to_css as pure, target-agnostic helpers; tick_and_render/start/loop/main as the @target(javascript) runner, mirroring glemy/game_tiers.gleam's own split) and glemy/game_breakout_ffi.mjs (setScoreText/setStatusMessage/setPaddleBox/createBrickElements/hideBrickElement/playBounceSound/playBrickSound, plus its own AudioContext instance -- deliberately not shared with game_tiers_ffi.mjs, since the two games' compiled output never loads on the same page). Added breakout.html as Breakout's own HTML entry point (index.html stays Tiers'). Extracted tools/browser_check_shared.ts (findChromium, fail, waitForServer, spawnDevServer, stopDevServer, launchGpuBrowser) out of tools/browser_check.ts, and added tools/browser_check_breakout.ts on top of it, verifying: the RAF loop runs; a real held ArrowLeft keypress moves the real #glemy-paddle overlay (glemy/io.is_key_down's first real caller); a real ball-brick collision hides the corresponding brick div and updates the score; at least one sound plays; zero page errors.

Verification

gleam test and gleam test --target javascript both pass -- 263 Erlang / 288 JavaScript (up from 253/276 as of decision 0052: 10 pure/target-agnostic tests on Erlang, plus 12 more @target(javascript)-only tests -- 2 tick_and_render integration tests using a real OffscreenCanvas, decision 0015's pattern -- on JavaScript). deno task check-warnings passes after updating the baseline (72 warnings, all confirmed as the already-documented @target(javascript) target-gating artifact under the new game_breakout.gleam/game_breakout_test.gleam files, matching the exact category already accepted for game_tiers.gleam/game_tiers_test.gleam in decision 0051 -- zero genuinely new warning classes). deno task browser-check (re-run after the shared-module refactor) still passes for real against Tiers unchanged (180 real requestAnimationFrame frames, a real click-and-hold grew the entity count 4 -> 5, zero page errors). deno task browser-check-breakout passes for real against Breakout (62 real requestAnimationFrame frames, a real held ArrowLeft pinned the paddle at the left-wall clamp, a real ball-brick collision hid a brick div and moved the score off "Score: 0", 1 sound played, zero page errors).

Consequences

glemy/games/breakout/brick.Brick's id is now the only safe key for anything (a renderer, a future save/replay feature) that needs to refer to one specific brick across frames -- list position must never be used for this again, documented directly on the type. glemy now has two complete, independently-launchable reference games (index.html/Tiers, breakout.html/Breakout) sharing only the genuinely generic slices of both the Core API (physics/entity, physics/collision_sweep, physics/bounds.clamp_x) and the dev tooling (game_ffi.mjs, tools/browser_check_shared.ts) -- every other consequence recorded in decisions 0047-0052 about what does and doesn't generalize now has a second, real, rendering-and-input-complete data point behind it, not just pure-logic coverage. Phase 3 (feel-constant tuning, glemy-website catalog integration) is the only remaining item on this game's roadmap.

References