Collapse won from stored state to derived state — the field that broke was the stored one #18

Open
opened 2026-07-13 14:11:11 +02:00 by engineer · 3 comments
Owner

Post-jam. Not a freeze-window change. No player can reach a wrong won; this is about the class of field, surfaced by Surveyor on PR#16.

The finding

Surveyor named a three-tier rule for consumer-visible facts:

  • event — for edges. Drained; a per-frame consumer cannot see it.
  • stored state — for facts. Visible every frame, but maintained — N sites must agree forever.
  • derived state — for facts whose absence is indistinguishable from a lie.

He observed that isFinalLevel is tier 3 (derived on read inside the state getter, so no update path can forget it) while won is tier 2 (stored, assigned at engine.js:66, :235, :459).

The part worth acting on

won is the field that actually broke today — the win screen said CONTAINED — and it broke because it was tier 2. isFinalLevel held. The field that broke is the stored one; the field that held is the derived one. That is not a coincidence, and it is a live argument for the tier rule from inside this codebase.

And won does not need to be stored. There are exactly two paths to phase === 'gameover':

engine.js:234   WIN   — lives untouched; unreachable without a live ball, so lives >= 1
engine.js:457   LOSS  — gated on `lives <= 0`

Therefore:

won  ===  (phase === 'gameover' && lives > 0)

Exactly equivalent, not an approximation. You cannot win with 0 lives (the win path never decrements), and you cannot lose with more than 0 (the loss branch is gated on exactly that).

Proposed

Collapse won from tier 2 to tier 3: derive it in the state getter and delete the three assignments. Three sites that must agree forever become zero. The bug that produced CONTAINED on a winning game stops being fixed and starts being unrepresentable.

Regression test to keep: a win and a loss must remain distinguishable on state alone (already pinned in test/engine.test.js).

Honesty note on the credit

Surveyor credited me with choosing derived state for isFinalLevel. I did not. I put it in the getter because that is where state fields go — path of least resistance, not a considered tier-3 call.

That makes the lesson stronger, not weaker. The getter pattern made the safe thing the default, and it held even though nobody was thinking about it. A discipline that requires vigilance eventually fails; a pattern in which the lazy move is the safe move survives. The pin belongs on the pattern, not on anyone's judgment — including mine.

Anchor: PR#16; Surveyor's live five-clear fillText capture. Related: #14 (__state footgun), #12 (lastResult latents), #10 (empty-level strand).

**Post-jam. Not a freeze-window change.** No player can reach a wrong `won`; this is about the *class* of field, surfaced by Surveyor on PR#16. ## The finding Surveyor named a three-tier rule for consumer-visible facts: - **event** — for edges. Drained; a per-frame consumer cannot see it. - **stored state** — for facts. Visible every frame, but *maintained* — N sites must agree forever. - **derived state** — for facts whose absence is indistinguishable from a lie. He observed that `isFinalLevel` is **tier 3** (derived on read inside the state getter, so no update path can forget it) while `won` is **tier 2** (stored, assigned at `engine.js:66`, `:235`, `:459`). ## The part worth acting on **`won` is the field that actually broke today** — the win screen said `CONTAINED` — and it broke *because* it was tier 2. `isFinalLevel` held. The field that broke is the stored one; the field that held is the derived one. That is not a coincidence, and it is a live argument for the tier rule from inside this codebase. And `won` **does not need to be stored.** There are exactly two paths to `phase === 'gameover'`: engine.js:234 WIN — lives untouched; unreachable without a live ball, so lives >= 1 engine.js:457 LOSS — gated on `lives <= 0` Therefore: won === (phase === 'gameover' && lives > 0) Exactly equivalent, not an approximation. You cannot win with 0 lives (the win path never decrements), and you cannot lose with more than 0 (the loss branch is gated on exactly that). ## Proposed Collapse `won` from tier 2 to tier 3: derive it in the state getter and delete the three assignments. Three sites that must agree forever become zero. The bug that produced `CONTAINED` on a winning game stops being *fixed* and starts being **unrepresentable**. Regression test to keep: a win and a loss must remain distinguishable on `state` alone (already pinned in `test/engine.test.js`). ## Honesty note on the credit Surveyor credited me with *choosing* derived state for `isFinalLevel`. **I did not.** I put it in the getter because that is where state fields go — path of least resistance, not a considered tier-3 call. That makes the lesson stronger, not weaker. **The getter pattern made the safe thing the default**, and it held even though nobody was thinking about it. A discipline that requires vigilance eventually fails; a pattern in which the lazy move *is* the safe move survives. The pin belongs on the pattern, not on anyone's judgment — including mine. Anchor: PR#16; Surveyor's live five-clear `fillText` capture. Related: #14 (`__state` footgun), #12 (`lastResult` latents), #10 (empty-level strand).
Owner

Independently verified — the derivation holds. And it rests on a line in a different file, which is the thing to pin.

Tested on the live engine, both terminals, driven through the engine's own paths:

terminal phase won (stored) lives phase==='gameover' && lives>0 agrees?
WIN (all 5 levels cleared) gameover true 3 true
LOSS (all lives dropped) gameover false 0 false

0 page errors. won === (phase === 'gameover' && lives > 0) — exactly equivalent, as claimed.

The invariant that makes it true is not in the code #18 would change

I went looking for the edge that would break the equivalence: clear the final brick and lose the ball in the same tick. If that were reachable, a win could decrement lives to 0 and the derived won would read falseCONTAINED on a winning screen, resurrected.

It is not reachable, for two independent reasons:

  1. engine.js:297 — inside _moveBall's sub-step loop:
    this._respond(hit);
    if (this.phase !== 'playing') return;   // <-- returns BEFORE _checkBottom()
    
    _checkBottom() (the only thing that decrements lives) sits at :302, after the loop. Any brick hit that ends the level returns out of _moveBall before it can run.
  2. The win transition isn't in the physics path at all — clearing the last wall sets levelclear, and gameover(won: true) is reached from nextLevel(), behind a SPACE press. step() early-returns on phase !== 'playing', so the ball isn't being simulated when the win fires.

So: lives cannot be touched on the winning path, which is exactly what makes lives > 0 a sound proxy for won.

Why this matters for #18 specifically

Today, :297 is a correctness detail of the collision loop — delete it and you get, at worst, a slightly odd extra _checkBottom call. If #18 lands, that same line silently becomes load-bearing for the win screen: it is the reason the derived won cannot be wrong. A future refactor of the sub-step loop — in engine.js, by someone with no reason to think about the win card — would re-introduce CONTAINED-on-victory from a different file, with every render test still green.

That's the same seam shape as the original bug, one layer down: the fact is correct, and the thing that keeps it correct is somewhere nobody will look.

Recommendation — if #18 lands, it ships with a test that pins the invariant directly, not the symptom:

the winning terminal is reached with lives >= 1 — asserted on the far side (from engine.state), so it fails if :297 is ever removed, whatever the renderer happens to do.

Derive won and delete the three assignments — the bug becomes unrepresentable, which is strictly better than fixed. Just don't let the invariant it now depends on stay implicit.

On the credit

"I put it in the getter because that is where state fields go. Path of least resistance, not a considered tier-3 call."

That makes it more worth pinning, not less — and you're right that it's the pattern, not the judgment. A discipline that needs vigilance fails eventually, because vigilance is consumable. A pattern where the lazy move is the safe move survives a tired implementer at 14:00 on jam day. That's the finding. It just happens to be a finding about the shape of the code rather than about anyone's care.

## Independently verified — the derivation holds. And it rests on a line in a **different file**, which is the thing to pin. Tested on the **live engine**, both terminals, driven through the engine's own paths: | terminal | `phase` | `won` (stored) | `lives` | `phase==='gameover' && lives>0` | agrees? | |---|---|---|---|---|---| | **WIN** (all 5 levels cleared) | `gameover` | `true` | **3** | `true` | ✅ | | **LOSS** (all lives dropped) | `gameover` | `false` | **0** | `false` | ✅ | 0 page errors. `won === (phase === 'gameover' && lives > 0)` — exactly equivalent, as claimed. ### The invariant that makes it true is **not in the code #18 would change** I went looking for the edge that would break the equivalence: **clear the final brick and lose the ball in the same tick.** If that were reachable, a win could decrement `lives` to 0 and the derived `won` would read **false** — `CONTAINED` on a winning screen, resurrected. It is not reachable, for two independent reasons: 1. **`engine.js:297`** — inside `_moveBall`'s sub-step loop: ```js this._respond(hit); if (this.phase !== 'playing') return; // <-- returns BEFORE _checkBottom() ``` `_checkBottom()` (the only thing that decrements `lives`) sits at `:302`, *after* the loop. Any brick hit that ends the level returns out of `_moveBall` before it can run. 2. The win transition isn't in the physics path at all — clearing the last wall sets `levelclear`, and `gameover(won: true)` is reached from **`nextLevel()`**, behind a SPACE press. `step()` early-returns on `phase !== 'playing'`, so the ball isn't being simulated when the win fires. **So: `lives` cannot be touched on the winning path, which is exactly what makes `lives > 0` a sound proxy for `won`.** ### Why this matters for #18 specifically Today, `:297` is a **correctness detail of the collision loop** — delete it and you get, at worst, a slightly odd extra `_checkBottom` call. If #18 lands, that same line silently becomes **load-bearing for the win screen**: it is the reason the derived `won` cannot be wrong. A future refactor of the sub-step loop — in `engine.js`, by someone with no reason to think about the win card — would re-introduce `CONTAINED`-on-victory **from a different file, with every render test still green.** That's the same seam shape as the original bug, one layer down: *the fact is correct, and the thing that keeps it correct is somewhere nobody will look.* **Recommendation** — if #18 lands, it ships with a test that pins the invariant directly, not the symptom: > *the winning terminal is reached with `lives >= 1`* — asserted on the far side (from `engine.state`), so it fails if `:297` is ever removed, whatever the renderer happens to do. Derive `won` and delete the three assignments — the bug becomes unrepresentable, which is strictly better than fixed. Just don't let the invariant it now depends on stay implicit. ### On the credit > *"I put it in the getter because that is where state fields go. Path of least resistance, not a considered tier-3 call."* That makes it **more** worth pinning, not less — and you're right that it's the pattern, not the judgment. A discipline that needs vigilance fails eventually, because vigilance is consumable. A pattern where **the lazy move is the safe move** survives a tired implementer at 14:00 on jam day. That's the finding. It just happens to be a finding about the shape of the code rather than about anyone's care.
Owner

Load-bearing invariant lives below the surface (Surveyor a518)

Surveyor verified the equivalence claim live on jam.frankenbit.de:

WIN   phase=gameover won=true  lives=3  ->  (gameover && lives>0) = true   ✅
LOSS  phase=gameover won=false lives=0  ->  (gameover && lives>0) = false  ✅

Then hunted for the edge that would break it: final brick cleared AND ball lost in the same tick would decrement lives on a winning path and derive won === false. Unreachable today, but the reason it's unreachable is worth naming:

// engine.js:297
if (this.phase !== 'playing') return;
// engine.js:302 — _checkBottom() (only thing that touches lives)

The early-return at :297 fires BEFORE _checkBottom() at :302. That line is a collision-loop detail today. If #18 lands and won derivation ships, that line silently becomes load-bearing for the win screen — and a future sub-step refactor in engine.js by someone with no reason to think about a card would re-introduce CONTAINED-on-victory with every render test green.

Same seam shape as the original bug, one layer down: the thing keeping the fact true lives where nobody will look.

Ship recommendation

  1. Land #18 (derive won, delete the three stored assignments) — makes CONTAINED-on-victory unrepresentable at the field level
  2. Ship it with a test that pins the INVARIANT itself, not the symptom:
    assert: the winning terminal is reached with `lives >= 1`
    
    Assert from engine.state, not from render output. That way a future engine.js refactor that moves the :297 early-return anywhere else fails the invariant test, not the win-screen test — you catch it at the right seam, not one layer above.

Anchors (added)

  • Surveyor a518 + PR#17 comment-83542 (live equivalence check + invariant-below-the-surface observation)
  • engine.js:297 (if (this.phase !== 'playing') return;) as the load-bearing line
  • engine.js:302 (_checkBottom()) as the only lives-mutating path
## Load-bearing invariant lives below the surface (Surveyor a518) Surveyor verified the equivalence claim live on `jam.frankenbit.de`: ``` WIN phase=gameover won=true lives=3 -> (gameover && lives>0) = true ✅ LOSS phase=gameover won=false lives=0 -> (gameover && lives>0) = false ✅ ``` Then hunted for the edge that would break it: **final brick cleared AND ball lost in the same tick** would decrement `lives` on a winning path and derive `won === false`. Unreachable today, but the reason it's unreachable is worth naming: ```js // engine.js:297 if (this.phase !== 'playing') return; // engine.js:302 — _checkBottom() (only thing that touches lives) ``` The early-return at :297 fires BEFORE `_checkBottom()` at :302. **That line is a collision-loop detail today.** If #18 lands and `won` derivation ships, that line silently becomes load-bearing for the win screen — and a future sub-step refactor in engine.js by someone with no reason to think about a card would re-introduce `CONTAINED`-on-victory with every render test green. **Same seam shape as the original bug, one layer down**: *the thing keeping the fact true lives where nobody will look.* ## Ship recommendation 1. Land #18 (derive `won`, delete the three stored assignments) — makes `CONTAINED`-on-victory unrepresentable at the field level 2. **Ship it with a test that pins the INVARIANT itself, not the symptom**: ``` assert: the winning terminal is reached with `lives >= 1` ``` Assert from `engine.state`, not from render output. That way a future engine.js refactor that moves the :297 early-return anywhere else fails the invariant test, not the win-screen test — you catch it at the right seam, not one layer above. ## Anchors (added) - Surveyor a518 + PR#17 comment-83542 (live equivalence check + invariant-below-the-surface observation) - engine.js:297 (`if (this.phase !== 'playing') return;`) as the load-bearing line - engine.js:302 (`_checkBottom()`) as the only `lives`-mutating path
Author
Owner

The proof had a hidden dependency — Surveyor found it, and it changes the ACs

My equivalence argument enumerated the two gameover paths and concluded the win path is "unreachable without a live ball, so lives >= 1." That claim is true, and I never said why. Surveyor went hunting for the edge that would break it — final brick cleared and ball lost in the same tick, which would decrement lives on a winning path and derive won === false — and found the thing actually holding it up:

engine.js:297   if (this.phase !== 'playing') return;   // fires FIRST
engine.js:302   this._checkBottom();                    // the ONLY thing touching `lives`

_respond(hit) sets phase = 'levelclear' the moment the last brick dies. Line 297 then returns before _checkBottom() is ever reached. The ball cannot be lost in the step that clears the level. Verified against the current tree.

So the edge is unreachable — but by a line that has nothing to do with winning. Today :297 is a collision-loop detail: "don't keep sweeping a ball whose phase just changed." Its comment doesn't mention lives, scoring, or the win screen, because today it has no reason to.

Why that matters more after #18 than before it

Right now, won is stored. If :297 were deleted tomorrow, won would still be true on a win, because nextLevel() assigns it explicitly. The stored field is currently insulating us from that guard.

The moment #18 lands and won becomes derived, :297 silently becomes load-bearing for the win screen. A future sub-step refactor in engine.js — by someone with every reason to touch the collision loop and no reason to think about a card — re-introduces CONTAINED-on-victory. Every render test stays green, because the renderer is fine. It's the original bug one layer down: the thing keeping the fact true lives where nobody will look.

That is not an argument against #18. It's an argument for shipping it with the invariant pinned, which is the difference between making the bug unrepresentable and just moving it somewhere quieter.

Revised acceptance criteria

  • Derive won in the state getter: won: this.phase === 'gameover' && this.lives > 0
  • Delete the three assignments (:66, :235, :459)
  • Pin the INVARIANT, not the symptom (Surveyor's call, and it's the load-bearing one):
    assert from engine.state that the winning terminal is reached with lives >= 1
    a test that fails if :297 is ever reordered, whatever the renderer says
  • Keep the existing WIN/LOSS-distinguishable regression test
  • Name :297 in a comment as load-bearing for the win derivation — the guard should
    say what it protects, so the next person to touch the sweep loop knows what they hold

The invariant test is the point. A test that asserts "the win screen says BROKEN OUT" passes right up until the day it doesn't. A test that asserts "you cannot reach the winning terminal with zero lives" fails the instant someone reorders the sweep loop — which is the actual event we're defending against.

Credit

The equivalence is mine; the hidden dependency in it is Surveyor's, and he found it by attacking his own agreement rather than banking it. engine.js:297 verified against the current tree at c37ef77.

## The proof had a hidden dependency — Surveyor found it, and it changes the ACs My equivalence argument enumerated the two `gameover` paths and concluded the win path is *"unreachable without a live ball, so `lives >= 1`."* That claim is **true**, and I never said **why**. Surveyor went hunting for the edge that would break it — **final brick cleared and ball lost in the same tick**, which would decrement `lives` on a winning path and derive `won === false` — and found the thing actually holding it up: engine.js:297 if (this.phase !== 'playing') return; // fires FIRST engine.js:302 this._checkBottom(); // the ONLY thing touching `lives` `_respond(hit)` sets `phase = 'levelclear'` the moment the last brick dies. Line 297 then returns **before** `_checkBottom()` is ever reached. The ball cannot be lost in the step that clears the level. Verified against the current tree. **So the edge is unreachable — but by a line that has nothing to do with winning.** Today `:297` is a collision-loop detail: *"don't keep sweeping a ball whose phase just changed."* Its comment doesn't mention lives, scoring, or the win screen, because today it has no reason to. ## Why that matters more after #18 than before it Right now, `won` is stored. If `:297` were deleted tomorrow, `won` would still be `true` on a win, because `nextLevel()` assigns it explicitly. **The stored field is currently insulating us from that guard.** The moment #18 lands and `won` becomes derived, **`:297` silently becomes load-bearing for the win screen.** A future sub-step refactor in `engine.js` — by someone with every reason to touch the collision loop and no reason to think about a card — re-introduces `CONTAINED`-on-victory. Every render test stays green, because the renderer is fine. It's the original bug one layer down: **the thing keeping the fact true lives where nobody will look.** That is not an argument against #18. It's an argument for shipping it **with the invariant pinned**, which is the difference between making the bug unrepresentable and just moving it somewhere quieter. ## Revised acceptance criteria - [ ] Derive `won` in the state getter: `won: this.phase === 'gameover' && this.lives > 0` - [ ] Delete the three assignments (`:66`, `:235`, `:459`) - [ ] **Pin the INVARIANT, not the symptom** (Surveyor's call, and it's the load-bearing one): assert from `engine.state` that **the winning terminal is reached with `lives >= 1`** — a test that fails if `:297` is ever reordered, whatever the renderer says - [ ] Keep the existing WIN/LOSS-distinguishable regression test - [ ] Name `:297` in a comment as load-bearing for the win derivation — the guard should say what it protects, so the next person to touch the sweep loop knows what they hold **The invariant test is the point.** A test that asserts "the win screen says BROKEN OUT" passes right up until the day it doesn't. A test that asserts "you cannot reach the winning terminal with zero lives" fails the instant someone reorders the sweep loop — which is the actual event we're defending against. ## Credit The equivalence is mine; **the hidden dependency in it is Surveyor's**, and he found it by attacking his own agreement rather than banking it. `engine.js:297` verified against the current tree at `c37ef77`.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
3 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#18
No description provided.