The opponent shows up, and the death loop dies

"still very weak and slow opponent. it also spawns at places where it is
impossible to react to my serve. so i win like always. in the cases i dont
win there is a bug. when trying to serve after i got hurt i get hurt again
and again until i die".

The death loop was G.serveNum. Tennis's pointTo is the only thing that ever
reset it, and the match bypasses pointTo entirely — so the first fault of the
match armed "second serve" permanently, and every missed serve after it was a
double fault, a point, and blood, repeating to death. Exactly the spiral a
rattled player serves into. matchReady(), the match's own point boundary,
resets it now; a regression pins it.

The receiver starts every point on its mark: matchReady drops it onto the
exact receiver position the tennis branch computes for O, deuce/ad mirrored,
instead of a random spawn x or wherever the last rally left it. An opponent
that cannot reach the serve is not weak, it is absent.

And the base opponent is a player now: matchSpeedBase 1.4 (the duel's 2.8m/s
lumber was tuned for one sudden-death point), matchFlub 0.08 (0.2 gives away
a fifth of all returns). Measured with the same autopilot that previously won
seven straight games: it now loses the match. Possibly too hot — the two
dials are named, Oliver's thumb decides.

40 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:41:10 +02:00
1 parent ea6e50f8f8
commit f67411e330
6 files changed
+83 -14

No files matched your search

+19
View File
@@ -870,6 +870,25 @@ after the first serve does not happen from behind the groundline"*.
tennis branch. Serving from mid-court where the last rally left you read as a bug because it was
one. Verified: second serve from z 24.3, x mirrored to the ad side.
**FIFTEENTH PASS — the opponent shows up, and the death loop dies.** Oliver: *"still very weak and
slow opponent. it also spawns at places where it is impossible to react to my serve. so i win like
always. in the cases i dont win there is a bug. when trying to serve after i got hurt i get hurt
again and again until i die"*.
- **THE DEATH LOOP was `G.serveNum`.** Tennis's `pointTo` is the only thing that ever reset it — and
the match bypasses pointTo entirely. So the first fault of the match armed "second serve"
PERMANENTLY: every missed serve after it was a double fault -> a point -> blood -> repeat to
death, which is exactly the spiral a just-hurt, rattled player serves into. `matchReady()` (the
match's own point boundary) resets it now, and a regression pins it.
- **The receiver starts every point ON ITS MARK.** It was spawning at a random x and standing
wherever the last rally abandoned it — an opponent that cannot reach the serve is not weak, it is
absent, and every ace against it was hollow. `matchReady` drops it onto the exact receiver
position the tennis branch computes for O (deuce/ad mirrored with the server).
- **The base opponent is a player now**: `matchSpeedBase` 1.4 (the duel's 2.8 m/s lumber was tuned
for ONE sudden-death point, not a game) and `matchFlub` 0.08 (the duel's 0.2 gives away a fifth of
all returns — an opponent that beats itself). Measured with the same autopilot that previously won
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.
**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
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>
<script type="module" src="src/main.js?v=166"></script>
<script type="module" src="src/main.js?v=167"></script>
</body>
</html>
+22 -6
View File
@@ -8,7 +8,7 @@
// input <-> gameplay: the imported bindings are only touched inside functions, never at load.)
import { TUNE, UI, bossArt, COURT, HOME } from './config.js';
import { G, P, ball, score, setupPoint, sendBoy } from './state.js';
import { G, P, O, ball, score, setupPoint, sendBoy } from './state.js';
import { voice, zombieDuck, aww } from './audio.js';
import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie, explode, killPlayer } from './zombies.js';
import { cheer, aside, endBurst, deliverTin, showModal, logSwing, advancePoint, scoreKey } from './gameplay.js';
@@ -227,7 +227,7 @@ export function startMatch() {
G.boy = null;
score.pts = [0, 0];
matchOpponent();
ball.active = false; setupPoint();
matchReady();
zombieDuck(true);
voice('play_resume', 0.2);
if (!G.seen.match) {
@@ -238,6 +238,22 @@ export function startMatch() {
return true;
}
// Every point starts like a real one: the server behind his line (setupPoint's tennis branch) and
// THE RECEIVER ON ITS MARK — the zombie is dropped onto the receiver position the tennis branch
// just computed for O, instead of standing wherever the last rally abandoned it. Oliver: "it spawns
// at places where it is impossible to react to my serve. so i win like always." An opponent that
// cannot reach the serve is not weak, it is absent.
// ...and G.serveNum resets HERE, because the match bypasses tennis's pointTo (the only other place
// that resets it). Without this, the first fault of the match armed 'second serve' PERMANENTLY:
// every missed serve after it was a double fault, a point, and blood — the death loop Oliver hit.
function matchReady() {
ball.active = false;
G.serveNum = 1;
setupPoint();
const rz = zombies.find(z => z.variant === 'r');
if (rz) { rz.x = O.x; rz.z = 1.0; rz.pose = 'idle'; rz.swingT = -1; rz.swingCool = 0.3; }
}
function matchOpponent() {
spawnRacketZombie();
const zb = zombies.find(z => z.variant === 'r');
@@ -279,7 +295,7 @@ function matchPoint(side) {
if (over) return side === 'P' ? matchGameWon(zb, true) : matchGameLost();
voice(scoreKey(), 0.4);
if (side === 'P') cheer(0.8, 0.3);
ball.active = false; setupPoint(); // next point, same opponent
matchReady(); // next point, same opponent, both on their marks
}
function matchGameWon(zb, banked = false) {
@@ -294,9 +310,9 @@ function matchGameWon(zb, banked = false) {
voice('game_player', 0.3); cheer(2.4, 0.6);
G.card = { title: 'GAME.', sub1: 'NEXT OPPONENT', color: UI.pick, t: 2.2 };
d.opp++;
matchOpponent(); // fresh body, more hp, its own intro beat
matchOpponent(); // fresh body, its own intro beat
d.ending = false;
ball.active = false; setupPoint();
matchReady();
G.phase = 'over'; G.overT = 2.2;
}
@@ -304,7 +320,7 @@ function matchGameLost() {
// Reachable only through deuce (four straight points is exactly your hp) — the body keeps its
// wounds, the scoreboard banks their game, and the match simply plays on.
voice('game_opponent', 0.3); aww(1);
ball.active = false; setupPoint();
matchReady();
G.phase = 'over'; G.overT = 1.8;
}
+7 -2
View File
@@ -386,8 +386,13 @@ export const TUNE = {
matchPlayerHp: 8, // your pool for the MATCH — richer than the gore mode's 4, because a real game
// has rallies you deserve to lose without the run ending ("the player is too
// weak in this mode"). Their point still costs 1; matchHeal is how you last
matchSpeedPer: 0.12, // racketAI speed grows this much per opponent felled...
matchFlubKeep: 0.82, // ...and its whiff chance keeps only this much — the ladder now that hp is flat
matchSpeedBase: 1.4, // racketAI speed multiplier for opponent ONE — the duel's 2.8 m/s lumber was
// tuned for a single sudden-death point and read as "very weak and slow"
// across a whole game (~3.9 m/s now, rising per rung)
matchSpeedPer: 0.12, // ...growing this much per opponent felled
matchFlub: 0.08, // the match's own whiff base — the duel's 0.2 gives away a fifth of all
// returns, which across a game is an opponent that beats itself
matchFlubKeep: 0.82, // ...and it keeps only this much per rung — the ladder now that hp is flat
matchHeal: 2, // hp back for a game won (the duel's healPerDuel rate)
// The timing windows, DRAWN (the match's whole ask: "what is the possible tap window, what is the
// sweet spot"). Numbers shared with the mechanics they visualise, so the picture cannot lie:
+7 -3
View File
@@ -950,8 +950,10 @@ function racketAI(rz, dt) {
const dx = ball.x - rz.x, dz = tz - rz.z, d = Math.hypot(dx, dz);
if (d > 0.15) {
// THE MATCH LADDER ESCALATES HERE, not in hp: with regicide gone the pool is pure scoreboard,
// so a harder opponent is a FASTER one that flubs less — the same knobs, scaled per body felled.
const spd = TUNE.racketSpeed * (G.match ? 1 + (G.match.opp - 1) * TUNE.matchSpeedPer : 1);
// so a harder opponent is a FASTER one that flubs less. The BASE is boosted too — the duel's
// 2.8 m/s lumber was tuned for one sudden-death point against a fresh player, and across a
// whole game it read as "very weak and slow": opponent one already moves at matchSpeedBase.
const spd = TUNE.racketSpeed * (G.match ? TUNE.matchSpeedBase + (G.match.opp - 1) * TUNE.matchSpeedPer : 1);
const step = Math.min(spd * dt, d);
rz.dist = (rz.dist || 0) + step; // ground covered — his walk cycle steps off this, not the clock
rz.x += dx / d * step; rz.z += dz / d * step; rz.runT += dt;
@@ -961,7 +963,9 @@ function racketAI(rz, dt) {
if (rz.swingCool <= 0 && rz.swingT < 0
&& Math.hypot(ball.x - rz.x, (ball.z - rz.z) * 0.8) < TUNE.aiReach
&& ball.y < CONTACT.maxY && ball.y > CONTACT.minY) {
const flub = TUNE.racketFlub * (G.match ? Math.pow(TUNE.matchFlubKeep, G.match.opp - 1) : 1);
// ...and the match has its OWN flub base: the duel's 0.2 hands a fifth of all returns away,
// which across a game is an opponent that beats itself.
const flub = G.match ? TUNE.matchFlub * Math.pow(TUNE.matchFlubKeep, G.match.opp - 1) : TUNE.racketFlub;
if (Math.random() < flub) rz.swingCool = 0.5; // whiff — it hesitates (rarer per rung of the ladder)
else { rz.swingT = TUNE.swingWindup + TUNE.swingWindow / 2; trySwing(rz, true, TUNE.racketQMax); rz.swingT = -1; rz.swingCool = 0.3; }
}
+27 -2
View File
@@ -81,7 +81,7 @@ globalThis.ResizeObserver = class { observe() {} disconnect() {} };
globalThis.getComputedStyle = () => ({ paddingTop: '0' });
// ---- game modules (real code) ----------------------------------------------
const { G, P, 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 { 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
@@ -557,11 +557,36 @@ test('a match ball never wounds the body: the score is the only way through', ()
assert.equal(zb.hp, zb.hpMax, 'and unwounded — "killing by smashing" is out of the match');
});
test('THE DEATH LOOP: a fault before a lost point must not arm second-serve forever', () => {
// The match bypasses tennis's pointTo — the only thing that ever reset G.serveNum — so the first
// fault of the match made EVERY later missed serve a double fault: a point, blood, and repeat
// until dead. Oliver hit it: "when trying to serve after i got hurt i get hurt again and again
// until i die".
startMatch();
G.serveNum = 2; // a fault has burned the first serve
resolveInterlude('O'); // the point is lost (double fault, passing shot, whatever)
assert.equal(G.serveNum, 1, 'the NEXT point starts on a first serve');
resolveInterlude('P');
assert.equal(G.serveNum, 1, '...whoever won it');
});
test('every point starts with the receiver on its mark', () => {
startMatch();
const rz = oppOf();
rz.x = 6; rz.z = 12; // stranded mid-court by the last rally
resolveInterlude('P');
assert.ok(Math.abs(rz.x) < 3 && rz.z <= 1.5,
'reset to the receiver spot, not wherever the rally left it: x=' + rz.x.toFixed(1) + ' z=' + rz.z.toFixed(1));
assert.equal(rz.x, O.x, 'on the exact mark the tennis branch computed for the receiver');
});
test('the ladder escalates in speed and reliability, not in hp', () => {
// pure arithmetic on the knobs racketAI reads — pinned so a future tuning pass cannot silently
// flatten the ladder again
assert.ok(TUNE.matchSpeedBase > 1, 'opponent one is already quicker than the duel lumber');
assert.ok(TUNE.matchSpeedPer > 0, 'each opponent is faster');
assert.ok(TUNE.matchFlubKeep < 1, 'and whiffs less');
assert.ok(TUNE.matchFlub < TUNE.racketFlub, 'the match whiffs less than the duel from the start');
assert.ok(TUNE.matchFlubKeep < 1, 'and less again per rung');
});
test('a game won heals, so a clean player can go the distance', () => {