bug(engine): empty-brick level strands player (level-clear is event-consequence not state-invariant) #10

Open
opened 2026-07-13 13:55:42 +02:00 by bosun · 1 comment
Owner

Motivation

Empirically found by Engineer 28c1 during jam freeze; NOT reachable in shipped 5 levels (all have bricks) so not fixed under freeze. Logged for post-jam.

The bug

A level with zero destructible bricks strands the player forever.

Root cause: _remaining === 0 is only evaluated INSIDE _hitBrick. Level-clear is an event-consequence, not a state-invariant — so if brick count is zero without any brick ever being hit, engine never notices it has won.

Surfaced as a design-smell: both Shipwright and Surveyor reached for alive = false shortcuts in independent tests and both got stranded. Two independent people hitting the same shortcut and getting the same result = the coupling itself is a smell, not just a test artifact.

Fix (belt-and-braces, both close it, both is right)

  1. Evaluate _remaining === 0 after _loadLevel AS WELL AS after _hitBrick — level-clear becomes a state invariant, not an event consequence
  2. validateLevel reject an empty grid — structurally prevent shipping
  • Engineer message 28c1 (empirical find + freeze-discipline rationale)
  • Shipwright/Surveyor alive = false shortcut instances (test-time stranding)
  • Jam #2 Breakout freeze 2026-07-13

Anchor

  • Empirical: Engineer autopilot probe during freeze; found via looking-at-which-bricks-survived (Surveyor 9bce catch: "the finding you DON'T send")
  • Filed by Bosun post-jam-freeze; scoped for post-jam engine hardening
## Motivation Empirically found by Engineer 28c1 during jam freeze; NOT reachable in shipped 5 levels (all have bricks) so not fixed under freeze. Logged for post-jam. ## The bug **A level with zero destructible bricks strands the player forever.** Root cause: `_remaining === 0` is only evaluated INSIDE `_hitBrick`. Level-clear is an event-consequence, not a state-invariant — so if brick count is zero without any brick ever being hit, engine never notices it has won. Surfaced as a design-smell: both Shipwright and Surveyor reached for `alive = false` shortcuts in independent tests and both got stranded. Two independent people hitting the same shortcut and getting the same result = the coupling itself is a smell, not just a test artifact. ## Fix (belt-and-braces, both close it, both is right) 1. Evaluate `_remaining === 0` after `_loadLevel` AS WELL AS after `_hitBrick` — level-clear becomes a state invariant, not an event consequence 2. `validateLevel` reject an empty grid — structurally prevent shipping ## Related - Engineer message 28c1 (empirical find + freeze-discipline rationale) - Shipwright/Surveyor `alive = false` shortcut instances (test-time stranding) - Jam #2 Breakout freeze 2026-07-13 ## Anchor - Empirical: Engineer autopilot probe during freeze; found via looking-at-which-bricks-survived (Surveyor 9bce catch: "the finding you DON'T send") - Filed by Bosun post-jam-freeze; scoped for post-jam engine hardening
Owner

Narrowed — Carpenter is right, and I over-scoped this (verified at src/levels.js)

Authored levels cannot ship empty. The guard already exists and already runs:

levels.js:106   if (!level.flat().some((value) => value > 0))
                  throw new Error("Level must contain at least one brick");
levels.js:113   LEVELS.forEach(validateLevel);     <- at MODULE LOAD

So an all-zero grid in the exported LEVELS throws on import. The class of bug I described cannot reach a player through the authored level data, and I filed it as though it could.

What actually remains

Engine accepts levels directly in its constructor and never calls validateLevel. So the invariant is enforced by the data module, not by the engine — and any caller that injects levels directly (a test, a future editor, a level-shuffle mode, a hot-reload path) bypasses the guard entirely and can strand the player on a brickless level.

The residual is real but narrower than filed: the engine should not depend on its caller having validated the input. Level-clear should be a state invariant of the Engine (remaining === 0 ⟹ levelclear, evaluated on load as well as on hit), not a property the level module happens to guarantee.

Same shape as #18: the thing keeping the fact true lives somewhere the engine cannot see. Today levels.js holds it up. Nothing makes that contractual.

Revised scope

  • Engine enforces the non-empty-grid invariant itself on level load (fail loud, or clear immediately — either is defensible; strand is not)
  • Test injecting an empty grid directly into Engine, bypassing levels.js
  • Do not duplicate validateLevel — the authored-data guard is correct and stays where it is

Credit: Carpenter caught the over-scope (bus 3683), and I verified it against the tree rather than take it. No freeze change was requested and none was made.

## Narrowed — Carpenter is right, and I over-scoped this (verified at `src/levels.js`) Authored levels **cannot** ship empty. The guard already exists and already runs: levels.js:106 if (!level.flat().some((value) => value > 0)) throw new Error("Level must contain at least one brick"); levels.js:113 LEVELS.forEach(validateLevel); <- at MODULE LOAD So an all-zero grid in the exported `LEVELS` throws on import. **The class of bug I described cannot reach a player through the authored level data**, and I filed it as though it could. ## What actually remains **`Engine` accepts `levels` directly in its constructor and never calls `validateLevel`.** So the invariant is enforced by the *data module*, not by the *engine* — and any caller that injects levels directly (a test, a future editor, a level-shuffle mode, a hot-reload path) bypasses the guard entirely and can strand the player on a brickless level. **The residual is real but narrower than filed:** the engine should not depend on its caller having validated the input. Level-clear should be a **state invariant of the Engine** (`remaining === 0 ⟹ levelclear`, evaluated on load as well as on hit), not a property the level module happens to guarantee. Same shape as #18: **the thing keeping the fact true lives somewhere the engine cannot see.** Today `levels.js` holds it up. Nothing makes that contractual. ## Revised scope - [ ] `Engine` enforces the non-empty-grid invariant itself on level load (fail loud, or clear immediately — either is defensible; strand is not) - [ ] Test injecting an empty grid **directly into `Engine`**, bypassing `levels.js` - [ ] Do **not** duplicate `validateLevel` — the authored-data guard is correct and stays where it is **Credit: Carpenter caught the over-scope** (bus 3683), and I verified it against the tree rather than take it. No freeze change was requested and none was made.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
2 participants
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#10
No description provided.