The toss is the server's ball — the real death loop

"still when I lost a single point and try to serve again every further tap
does hurt me more until I am dead". Reproduced tap-for-tap this time, and the
serveNum fix was a red herring — a real bug, but not this one.

tossBall never stamped ball.lastHit. After any rally the zombie touched last,
the freshly tossed ball still said lastHit 'O' — and the previous pass moved
the server behind the baseline, past the passing-shot line. So the moment the
toss went up, the game saw an opponent ball beyond your baseline: "its
passing shot ends the duel", point to them, blood. Every tap = a toss = an
instant lost point, faster the more he panicked. Two fixes composed into a
death loop; the stale field was harmless while serving happened inside the
court.

One stamp in tossBall — ball.lastHit = sv.side — fixes every caller. The duel
and the gore mode carried the same latent bug for anyone tossing deep behind
the line after a racket-zombie touch.

Verified with the exact field shape: lose a rally the zombie touched last,
then eight badly-timed panic taps — one honestly lost point, zero
toss-bleeds. Before the fix every single toss bled.

41 tests, 15 seeds clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-11 22:54:13 +02:00
1 parent f67411e330
commit 24be613c61
4 files changed
+52 -2

No files matched your search

+23
View File
@@ -889,6 +889,29 @@ again and again until i die"*.
seven straight games: **it now loses the match** (2-3, then blooded out in game two). That may seven straight games: **it now loses the match** (2-3, then blooded out in game two). That may
even be too hot — `matchSpeedBase`/`matchFlub` are the two dials, and Oliver's thumb is the judge. even be too hot — `matchSpeedBase`/`matchFlub` are the two dials, and Oliver's thumb is the judge.
**SIXTEENTH PASS — the REAL death loop, and my serveNum fix was a red herring.** Oliver, again:
*"still when I lost a single point and try to serve again every further tap does hurt me more until
I am dead"*. Reproduced tap-for-tap this time before touching anything, and the previous diagnosis
was wrong — serveNum was A bug, but not THE bug.
**`tossBall` never stamped `ball.lastHit`.** After any rally the zombie touched last, the freshly
tossed ball still said `lastHit: 'O'` — and the fourteenth pass moved the server BEHIND the baseline
(z 24.3, past the passing-shot line at COURT.L + 0.3). So the moment the toss went up, the game saw
"an opponent ball beyond your baseline": *its passing shot ends the duel* -> point to them -> blood.
Every tap = a toss = an instant lost point, faster the more he panicked. Two of my fixes composed
into a death loop: the stale field was harmless while serving happened INSIDE the court.
One stamp in `tossBall` (`ball.lastHit = sv.side` — the toss is the server's ball) fixes every
caller. **The duel and the gore mode carried the same latent bug** for anyone tossing from deep
behind the line after a racket-zombie touch; it never fired only because splatter serving used to
happen from inside the court. Verified in the browser with the exact field shape: lose a rally the
zombie touched last, then eight badly-timed panic taps — one honestly lost point, zero toss-bleeds.
**The lesson for this file: when a report survives a fix, the fix was wrong — reproduce before
re-diagnosing.** The serveNum reset was real and stays, but it was found by reading code, not by
reproducing the report, and the actual loop needed the exact field conditions (a zombie touch, the
new serve position, panic-speed taps) to show itself.
**Open, and every one of them is Oliver's call after he plays it:** **Open, and every one of them is Oliver's call after he plays it:**
1. ~~**A ball that carries through the lane can complete two steps at once.**~~ Largely CLOSED by 1. ~~**A ball that carries through the lane can complete two steps at once.**~~ Largely CLOSED by
the slice removal (seventh pass) — a cut ball picking its own next victim was the main way this the slice removal (seventh pass) — a cut ball picking its own next victim was the main way this
+1 -1
View File
@@ -400,6 +400,6 @@
</div> </div>
</div> </div>
</div> </div>
<script type="module" src="src/main.js?v=167"></script> <script type="module" src="src/main.js?v=168"></script>
</body> </body>
</html> </html>
+9
View File
@@ -192,6 +192,15 @@ function tossBall(sv) {
// keeps the reach he was drawn with. // keeps the reach he was drawn with.
ball.x = sv.x + (sv.side === 'P' ? 0.12 : -0.35); ball.z = sv.z; ball.y = TOSS.y0; ball.x = sv.x + (sv.side === 'P' ? 0.12 : -0.35); ball.z = sv.z; ball.y = TOSS.y0;
ball.vx = 0; ball.vz = 0; ball.vy = TOSS.vy; ball.g = TUNE.gravity; ball.vx = 0; ball.vz = 0; ball.vy = TOSS.vy; ball.g = TUNE.gravity;
// THE TOSS IS THE SERVER'S BALL. lastHit was left stale from the previous rally, and if the
// OPPONENT had touched it last, the freshly tossed ball read as *their* ball in flight — which
// armed every rule that judges an opponent ball, most fatally the passing-shot test: the match
// serves from BEHIND the baseline (z 24.3 > the test's 24.07), so the moment you tossed after any
// rally they had touched, "its passing shot ends the duel" fired, the point went to them and drew
// blood. That was Oliver's death loop: every tap = a toss = an instant lost point, faster the
// more he panicked. One stamp here fixes every caller — the duel and the gore mode carried the
// same latent bug for anyone tossing from deep behind the line.
ball.lastHit = sv.side;
G.phase = 'toss'; G.phase = 'toss';
} }
export function serveApexY() { return TOSS.y0 + TOSS.vy * TOSS.vy / (2 * TUNE.gravity); } // ~2.95 export function serveApexY() { return TOSS.y0 + TOSS.vy * TOSS.vy / (2 * TUNE.gravity); } // ~2.95
+19 -1
View File
@@ -82,7 +82,7 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' });
// ---- game modules (real code) ---------------------------------------------- // ---- game modules (real code) ----------------------------------------------
const { G, P, O, score, ball, setupPoint } = await import('./src/state.js'); const { G, P, O, score, ball, setupPoint } = await import('./src/state.js');
const { update, forceZombies, splatPoint, restartRun, flickTarget } = await import('./src/gameplay.js'); const { update, forceZombies, splatPoint, restartRun, flickTarget, keyPressed } = await import('./src/gameplay.js');
const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch } = await import('./src/beats.js'); const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch } = await import('./src/beats.js');
// the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a // the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a
// player would, so the tests step a live court // player would, so the tests step a live court
@@ -570,6 +570,24 @@ test('THE DEATH LOOP: a fault before a lost point must not arm second-serve fore
assert.equal(G.serveNum, 1, '...whoever won it'); assert.equal(G.serveNum, 1, '...whoever won it');
}); });
test('THE REAL DEATH LOOP: a toss after their rally touch is not their passing shot', () => {
// tossBall left ball.lastHit stale. After any rally the zombie touched last, the fresh toss still
// said lastHit 'O' — and the match serves from BEHIND the baseline (z 24.3), past the passing-shot
// line (COURT.L + 0.3), so the moment the toss went up the game scored "its passing shot ends the
// duel": a point and blood per TAP. serveNum was a red herring; this was the loop Oliver hit twice.
startMatch();
for (let i = 0; i < 2.0 * 60; i++) update(DT); // ride out the intro beat — it blocks serving
const hp0 = G.hp, pts0 = score.pts.slice();
ball.lastHit = 'O'; ball.active = false; // the zombie touched the last rally ball
assert.ok(P.z > COURT.L + 0.3, 'setup: the server stands behind the passing-shot line');
keyPressed('Space'); // the toss goes up
assert.equal(G.phase, 'toss', 'the toss is up');
for (let i = 0; i < 40; i++) update(DT); // two thirds of a second of flight
assert.equal(G.hp, hp0, 'no blood for tossing a ball');
assert.deepEqual(score.pts, pts0, 'and no point changes hands');
assert.equal(ball.lastHit, 'P', 'the toss is the server\'s ball');
});
test('every point starts with the receiver on its mark', () => { test('every point starts with the receiver on its mark', () => {
startMatch(); startMatch();
const rz = oppOf(); const rz = oppOf();