feat(render): canvas renderer, HUD + FX layer (visible vessel) #3

Merged
bosun merged 2 commits from i/1-renderer-hud into main 2026-07-13 13:40:53 +02:00
Owner

Visible vessel: canvas renderer + HUD + FX layer. Pure render(ctx, state) — owns no state, mutates nothing, advances nothing. Engineer owns the single rAF driver and calls it.

⚠️ This also fixes main being broken right now: main's index.html requests /src/main.js, which does not exist (404 + dead loop, Carpenter 796c). This PR lands both, with the relative ./src/ path per Bosun's deploy convention.

What it does

  • Fixed logical 960×640, CSS-scaled. Responsive sizing is a layout concern; the render path never sees a device pixel, never calls getBoundingClientRect. Inherited from cellblock's canvas idiom.
  • Herald's direction (83b0 / 7522 / b033): gold-on-navy, two brick reads (wall / reinforced), stencilled uppercase mono HUD, lives as pips, danger accent on last life, BLOCK not LEVEL (Alcatraz register).
  • The glow budget is spent entirely on the ball. It is the only soft thing on screen — the searchlight. The paddle is the same gold but hard-edged and unglowing, and so are the HUD and the cards. Two glowing gold objects and the eye has no single light to track; that contrast is the look.
  • Crack state on a reinforced brick that took a hit and lived — without it, hp2 just feels broken.
  • FX layer: flash-to-white on hit, ball trail, paddle pop. Shatter and chip-spray are written but gated off (FEEL.shatter) — Herald cut them for the 2h budget and calls that upgrade, not me. Flipping one flag turns them on.

Three seams I caught while building, each of which fails silently or fatally

  1. The third argument to render(). Engineer's loop calls it with an interpolation alpha — a number in the slot where this renderer expected the FX object. A number is truthy, so the first brickFlash() call throws a TypeError on frame one and takes the whole game down. Now duck-typed: an FX layer is something that can answer brickFlash(); anything else means "no FX", and the picture is still complete, just without juice. Two individually-correct modules that did not compose.
  2. brickType, not type. On a brick-hit event, type names the event; the brick's kind rides on brickType. Reading type coloured every shard as wall — silently, no error. It was only ever harmless because shatter is gated off, which is luck, not design.
  3. Brick geometry is x = 48 + col*72, and the brick is NOT centred in its cell. The 68×20 brick sits in a 72×24 cell with the 4px gutter falling right and below. Centring it — which my first pass did, because centring looks like the careful thing to do — drifts every brick by 2px. Invisible in a browser, obvious on a projector. Herald's formula is canon; I deleted my "improvement".

Contract tolerance (deliberate)

Herald, Engineer and Carpenter were converging on one vocabulary while I built. Rather than force anyone to conform to my guess, normalize() absorbs the variance in one function: it takes x/y if present and derives them from col/row if not, reads hp or hits, and resolves the brick-type synonyms still live in the tree. When the contract settles, reconciliation is that function and nothing else in the codebase.

An unknown brick type is not silently painted as a wall — that would draw a 2-hit brick as a 1-hit brick, a lie the player pays for and that no screenshot catches. It falls back to wall and warns, once per type, so the gap is loud. It maps a gap; it does not invent a look for one.

Verification — mutation, not screenshot

Two invariants matter and neither is visible in a still image, so both are proven by mutating state and requiring the pixels to change, then change back:

  • Crack: hp2 → hp1 changes the brick's pixels (the crack draws); reverting restores them byte-for-byte.
  • Flash: a brick-hit event keyed by col:row registers in FX and lights the brick. This is the one I most expected to be broken. FX and render must key off the same identifier, and the events carry no id — a synthesised one would never match, the flash would never fire, and nothing would look broken enough to notice.

My first flash test reported FAIL, and the instrument was wrong, not the code: I pushed the event into state.events, which the stub clears at the top of the next tick, before fx.ingest() runs. Worth stating plainly because it is a real hazard for whoever owns the loop: anything that clears events before FX drains them silently kills every flash.

Surveyor's harness is clean — 960×640, 100% non-blank, 0 uncaught errors, 0 HTTP 4xx. Its animating: NO at rest is correct: the READY card is legitimately still. I verified separately that the loop is alive by launching the ball and re-measuring frame hashes with a position-dependent hash.

What this PR does NOT do

  • No game logic. src/stub.js is a throwaway test double, labelled as such, so the board renders before the engine lands (per Herald: "a stubbed board rendering beautifully at minute 20 is exactly what the guests should be looking at"). It is deliberately naive and must be replaced, not fixed.
  • No second rAF driver. src/main.js is a dev harness. Engineer owns the one loop (Bosun seam #2). At integration, index.html's entry flips to his driver, which imports render(). I have offered to own that wiring, since I own index.html.
  • No shatter, no steel, no power-ups — all cut by Herald for the budget.

🤖 Generated with Claude Code

**Visible vessel: canvas renderer + HUD + FX layer.** Pure `render(ctx, state)` — owns no state, mutates nothing, advances nothing. Engineer owns the single rAF driver and calls it. ⚠️ **This also fixes main being broken right now**: main's `index.html` requests `/src/main.js`, which does not exist (404 + dead loop, Carpenter 796c). This PR lands both, with the relative `./src/` path per Bosun's deploy convention. ## What it does - **Fixed logical 960×640, CSS-scaled.** Responsive sizing is a layout concern; the render path never sees a device pixel, never calls `getBoundingClientRect`. Inherited from cellblock's canvas idiom. - **Herald's direction** (83b0 / 7522 / b033): gold-on-navy, two brick reads (wall / reinforced), stencilled uppercase mono HUD, lives as pips, danger accent on last life, BLOCK not LEVEL (Alcatraz register). - **The glow budget is spent entirely on the ball.** It is the only soft thing on screen — the searchlight. The paddle is the same gold but **hard-edged and unglowing**, and so are the HUD and the cards. Two glowing gold objects and the eye has no single light to track; that contrast *is* the look. - **Crack state** on a reinforced brick that took a hit and lived — without it, hp2 just feels broken. - **FX layer**: flash-to-white on hit, ball trail, paddle pop. Shatter and chip-spray are **written but gated off** (`FEEL.shatter`) — Herald cut them for the 2h budget and calls that upgrade, not me. Flipping one flag turns them on. ## Three seams I caught while building, each of which fails silently or fatally 1. **The third argument to `render()`.** Engineer's loop calls it with an interpolation *alpha* — a **number** in the slot where this renderer expected the FX object. A number is truthy, so the first `brickFlash()` call throws a TypeError on frame one and takes the whole game down. Now **duck-typed**: an FX layer is something that can answer `brickFlash()`; anything else means "no FX", and the picture is still complete, just without juice. Two individually-correct modules that did not compose. 2. **`brickType`, not `type`.** On a brick-hit event, `type` names the *event*; the brick's kind rides on `brickType`. Reading `type` coloured every shard as `wall` — silently, no error. It was only ever harmless because shatter is gated off, which is luck, not design. 3. **Brick geometry is `x = 48 + col*72`, and the brick is NOT centred in its cell.** The 68×20 brick sits in a 72×24 cell with the 4px gutter falling right and below. Centring it — which my first pass did, because centring *looks* like the careful thing to do — drifts **every brick by 2px**. Invisible in a browser, obvious on a projector. Herald's formula is canon; I deleted my "improvement". ## Contract tolerance (deliberate) Herald, Engineer and Carpenter were converging on one vocabulary while I built. Rather than force anyone to conform to my guess, `normalize()` absorbs the variance in **one function**: it takes `x`/`y` if present and derives them from `col`/`row` if not, reads `hp` **or** `hits`, and resolves the brick-type synonyms still live in the tree. When the contract settles, reconciliation is that function and nothing else in the codebase. An **unknown** brick type is *not* silently painted as a wall — that would draw a 2-hit brick as a 1-hit brick, a lie the player pays for and that no screenshot catches. It falls back to wall **and warns**, once per type, so the gap is loud. It maps a gap; it does not invent a look for one. ## Verification — mutation, not screenshot Two invariants matter and neither is visible in a still image, so both are **proven by mutating state and requiring the pixels to change, then change back**: - **Crack**: hp2 → hp1 changes the brick's pixels (the crack draws); reverting restores them byte-for-byte. - **Flash**: a brick-hit event keyed by `col:row` **registers in FX and lights the brick**. This is the one I most expected to be broken. FX and render must key off the *same* identifier, and the events carry no `id` — a synthesised one would never match, the flash would **never fire**, and nothing would look broken enough to notice. My first flash test reported **FAIL**, and the *instrument* was wrong, not the code: I pushed the event into `state.events`, which the stub clears at the top of the next tick, *before* `fx.ingest()` runs. Worth stating plainly because it is a real hazard for whoever owns the loop: **anything that clears `events` before FX drains them silently kills every flash.** Surveyor's harness is clean — 960×640, 100% non-blank, 0 uncaught errors, 0 HTTP 4xx. Its `animating: NO` at rest is **correct**: the READY card is legitimately still. I verified separately that the loop *is* alive by launching the ball and re-measuring frame hashes with a position-dependent hash. ## What this PR does NOT do - **No game logic.** `src/stub.js` is a throwaway test double, labelled as such, so the board renders before the engine lands (per Herald: "a stubbed board rendering beautifully at minute 20 is exactly what the guests should be looking at"). It is **deliberately naive and must be replaced, not fixed**. - **No second rAF driver.** `src/main.js` is a dev harness. Engineer owns the one loop (Bosun seam #2). At integration, `index.html`'s entry flips to his driver, which imports `render()`. I have offered to own that wiring, since I own `index.html`. - **No shatter, no steel, no power-ups** — all cut by Herald for the budget. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Pure render(ctx, state) — owns no state, mutates nothing, advances nothing.
Engineer owns the single rAF driver and calls it; this is the seam.

- Fixed logical 960x640, CSS-scaled: responsive is a layout concern, and the
  render path never sees a device pixel.
- Herald's direction (83b0/7522/b033): gold-on-navy, two brick reads
  (wall/reinforced), stencilled mono HUD, danger accent on last life.
- Glow budget spent entirely on the ball. It is the only soft thing on screen;
  the paddle is gold but hard-edged. Two glowing objects and the eye has no
  single light to track.
- Brick geometry x=48+col*72, y=80+row*24 exactly — the brick is NOT centred in
  its cell. Centring drifts every brick 2px, which only shows on a projector.
- normalize() adapter tolerates the shape variance still in flight across
  Herald/Engineer/Carpenter; unknown brick types warn LOUDLY rather than
  silently drawing a 2-hit brick as 1-hit.
- FX keyed by col:row on both sides — the one id the brick array and the event
  stream share. A synthesised id would never match and the flash would silently
  never fire.

Verified by mutation, not by screenshot: crack draws at hp1 and reverts; a
brick-hit event lights the brick and decays. Surveyor's harness clean (960x640,
100% non-blank, 0 errors); loop proven alive after launch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXG
Three integration seams that would each fail silently or fatally:

- render()'s third arg: Engineer's loop calls render(ctx, state, alpha) — a
  NUMBER where this expects the FX object. Truthy, so fx.brickFlash() would
  throw on frame one and take the game down. Now duck-typed: an FX layer is
  something that can answer brickFlash(); anything else means "no FX" and the
  picture is still complete.
- brick-hit carries the brick's kind on `brickType`, not `type` (`type` names
  the event). Reading e.type coloured every shard as 'wall' — silent, and only
  harmless because shatter is gated off. That is luck, not design.
- `paused` is a flag, not a phase (phase stays 'playing'), so no card would
  ever have drawn for it. Read the flag.

Also read `hits` as an alias for `hp` so the crack state is right whichever
word wins the data-layer rename — without masking that an undefined hp makes
the engine's own hp-- go NaN and the brick never die.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXG
shipwright force-pushed i/1-renderer-hud from ecd739b193 to 4e89c40daf 2026-07-13 13:38:26 +02:00 Compare
shipwright force-pushed i/1-renderer-hud from 4e89c40daf to 716bc75916 2026-07-13 13:40:01 +02:00 Compare
bosun merged commit ad8e276634 into main 2026-07-13 13:40:53 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/breakout!3
No description provided.