The receiver stands where the serve can actually go

The trick: hard serve, slight slide, and the first ten opponents died where
they stood. Measured headless — that serve was returned 0 times out of 24 at
EVERY rung. Three things were wrong and all three were the same thing: he
waited on the tennis mark 1.3 m off the centre line (covering the T, conceding
the whole corner), he took no first step, and he chased the ball's LIVE
position, which during a serve is still up by the server's baseline.

Now: he waits on the bisector of the serves he has to cover, takes a
returner's first step (serve-scoped — the rally is untouched), and runs at the
bounce instead of at the ball. The slid serve comes back from rung one; a
flush serve into the very corner of the box still aces the bottom of the
ladder, which is the reward placement is supposed to earn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-15 20:29:17 +02:00
1 parent ece27827e5
commit 86b8275f5d
6 files changed
+77 -10

No files matched your search

+1 -1
View File
@@ -659,6 +659,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
</div>
</div>
</div>
<script type="module" src="src/main.js?v=273"></script>
<script type="module" src="src/main.js?v=274"></script>
</body>
</html>
+7
View File
@@ -421,6 +421,13 @@ export function matchReady() { // exported for net.js: a paired death match re-p
// machinery is driven off — on exactly that spot, so the zombie simply takes it. One source for
// the mark, which is what stops the picture and the ballistics disagreeing about where it stands.
if (rz) {
// WHERE A RETURNER ACTUALLY STANDS. The tennis mark is 1.3 m off the centre line, and from
// there he bisects nothing: the T is one step away and the corner of the box is four. Measured
// headless, a flush serve with full sway into that corner was returned 0 times out of 20 at
// EVERY rung of the ladder — Oliver: "the first ten zombies can be killed the moment you
// serve". He now stands out on the bisector of the two serves he has to cover, which is what a
// real receiver does, and O takes the same mark so the picture and the ballistics still agree.
if (score.server === 'P') O.x = Math.sign(O.x || 1) * TUNE.matchRecvX;
rz.x = O.x; rz.z = score.server === 'O' ? O.z : 1.0;
rz.pose = 'idle'; rz.swingT = -1; rz.swingCool = 0.3;
rz.showFrame = null; rz.serveHold = 0;
+7
View File
@@ -581,6 +581,13 @@ export const TUNE = {
racketStride: 0.42, // m the racket zombie covers per walk frame — his cycle steps off GROUND
// COVERED, so he stops animating when he stops moving
racketSpeed: 2.8, // m/s the racket zombie shambles to chase a ball
aiRead: 0.8, // how much of the BOUNCE the racket zombie reads while the ball is in the air:
// 0 = the old pure pursuit of the live ball (a hard, angled serve was an
// automatic ace — 0/40 returned, measured), 1 = never wrong-footed. See racketAI.
matchRecvX: 2.45, // m off the centre line the match opponent WAITS on, receiving. The tennis mark
// (1.3) covers the T and concedes the corner; the box runs to 4.1. See matchReady.
aiReturnDash: 1.3, // ...and how much faster he moves for the SERVE only, the first step a
// returner takes. Serve-scoped on purpose: the rally is unchanged.
racketQMax: 0.55, // its return quality is capped here (slower, sloppier than the tennis CPU)
racketFlub: 0.2, // chance the racket zombie whiffs a return
missHud: 0, // show the last miss reason on screen (full log: T.G.missLog) — off; dev-only
+29 -7
View File
@@ -365,7 +365,10 @@ export function sliceCarom(tgt, speed) {
// `perfect` is passed SEPARATELY from `quality` because the two serves earn it differently: the toss
// ritual earns it on a 45 ms timing window, while the slingshot's quality saturates at pullFull and
// so can never fail the old `quality > 0.999` test once you have pulled far enough.
function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
// exported for the headless suite only: it is the one honest way to serve without a thumb, and a
// serve-defence test that fakes its own ball is testing a ball the game never hits (it did, once —
// the fake was a flat rocket that skidded out the back and nothing could have returned it).
export function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
const toFar = sv.side === 'P';
// {sx, hard} is the TENNIS strike aim (see keyPressed's toss branch): sx sways the target in
// SCREEN direction — flick right, serve right — and hard buys pace and depth, paid for in
@@ -1010,8 +1013,16 @@ function matchFallWillPlay() {
return racketStage({ hp: after, hpMax: rz.hpMax }) !== racketStage(rz); // a limb
}
// How fast this body chases a ball. ONE definition, because racketAI's legs and the two
// out-of-reach tests that end a rally early must never disagree about what it can get to.
// THE RETURN DASH is the returner's first step: a serve is the one ball in the game that is
// hit before he is moving at all, and against a hard one into the corner the walk-up simply
// could not start in time (Oliver: the first ten opponents can be killed the moment you serve).
// It lasts exactly as long as the serve does — G.wasServe is cleared at the first bounce and at
// any return — so a rally is chased at the ordinary ladder speed and nothing else moves.
const rzChaseSpeed = rz => TUNE.racketSpeed
* (TUNE.matchSpeedBase + (G.match.opp - 1) * TUNE.matchSpeedPer) * ((rz.foe && rz.foe.speed) || 1);
* (G.match ? (TUNE.matchSpeedBase + (G.match.opp - 1) * TUNE.matchSpeedPer) * ((rz.foe && rz.foe.speed) || 1) : 1)
* (G.wasServe && ball.lastHit === 'P' ? TUNE.aiReturnDash : 1);
// Can the match opponent still reach this ball before its second bounce? Closed-form flight from
// the POST-BOUNCE state (velocities already damped when the line call runs) against the body's own
@@ -1279,16 +1290,27 @@ function racketAI(rz, dt) {
const incoming = ball.active && ball.lastHit === 'P' && ball.vz < 0
&& (G.interlude || (ball.pow || 0) < TUNE.zSplatQ); // in the duel it plays EVERY ball — it's tennis now
if (!incoming) { if (rz.pose === 'run') rz.pose = 'idle'; return; }
const tz = Math.max(1.5, Math.min(COURT.svcFar, ball.z)); // chase laterally, hold deep on the far court
const dx = ball.x - rz.x, dz = tz - rz.z, d = Math.hypot(dx, dz);
// ANTICIPATION. He used to run at the ball's LIVE position, and against a serve that is a
// trap: for the first third of the flight the ball is still up by YOUR baseline, so he ran
// deep and straight while the ball was already leaving sideways, and only started sliding
// across once it was past him. Measured headless: a flush, hard serve into the corner of the
// box was returned 0 times out of 40 at every rung of the ladder — Oliver's "you can kill the
// first ten of them the moment you serve". He reads the BOUNCE now and runs at that instead.
// aiRead is how much of the read he gets: 1 would be a machine that is never wrong-footed, and
// being wrong-footed is most of what makes the rally a rally. Serves only in spirit but applied
// to every flight — the same lag was there in a rally, just hidden by the shorter distances.
const pred = ball.y > 0.35 && ball.vy < 3 ? predictLanding() : null; // not while it is still climbing off the racket
const aimX = pred ? ball.x + (pred.x - ball.x) * TUNE.aiRead : ball.x;
const aimZ = pred ? ball.z + (pred.z - ball.z) * TUNE.aiRead : ball.z;
const tz = Math.max(1.5, Math.min(COURT.svcFar, aimZ)); // chase laterally, hold deep on the far court
const dx = aimX - rz.x, dz = tz - rz.z, d = Math.hypot(dx, dz);
if (d > 0.15) {
// THE MATCH 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 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)
* (rz.foe?.speed || 1) : 1);
const step = Math.min(spd * dt, d);
// (the dials themselves live in rzChaseSpeed, which the rally-ending reach tests share)
const step = Math.min(rzChaseSpeed(rz) * 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;
if (rz.pose !== 'swing') rz.pose = 'run';
+1 -1
View File
@@ -85,7 +85,7 @@ function measureSafe(scale) {
// BLOOD panel both sit 6 logical px in from the top-right); the DOM chips on top of it are laid
// out in CSS px — and on a phone one logical px is ~1.4 CSS px, so the same "6" put the quit
// chip a visible notch above the panels it is meant to line up with. One variable, one grid.
document.documentElement.style.setProperty('--hud', 6 * scale + 'px');
document.documentElement?.style.setProperty('--hud', 6 * scale + 'px'); // ?. — the headless suite has no root element
}
// The page background behind the canvas. It shows in exactly two places: the strip above the canvas
+32 -1
View File
@@ -84,7 +84,7 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' });
// ---- game modules (real code) ----------------------------------------------
const state = await import('./src/state.js');
const { G, P, O, score, ball, setupPoint } = state;
const { update, forceZombies, startGore, splatPoint, restartRun, flickTarget, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap } = await import('./src/gameplay.js');
const { update, forceZombies, startGore, splatPoint, restartRun, flickTarget, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe } = await import('./src/gameplay.js');
const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN, startNetVs, endNetVs } = await import('./src/beats.js');
const { mirror, netMsg, netTest, netTick } = await import('./src/net.js');
// the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a
@@ -1083,6 +1083,37 @@ test('REACHABILITY: an auto-run player can cover the first opponent\'s returns',
} finally { TUNE.autoAssist = assist; }
});
test('a served ball with a slide on it is no longer a free kill', () => {
// Oliver: "the moment you serve you can serve hard with a slight slide and almost all of them
// fail to get the ball" — the first ten opponents died to one gesture. Measured before the fix:
// a flush, hard, swayed serve was returned 0 times out of 24 at EVERY rung of the ladder, because
// the receiver waited on the tennis mark (1.3 m off the centre line, covering the T and conceding
// the whole corner) and then chased the ball's LIVE position, which during a serve is still up by
// the server's baseline. Mark, first step and read are all dialled in config; this asserts the
// EXPERIENCE, not the dials, exactly like the reachability soak above.
// A serve into the very corner of the box is deliberately still an ace at the bottom of the
// ladder — the trick that had to die is the one that needed no placement.
const serve = sx => {
freshSplatter();
startMatch();
for (let i = 0; i < 2.2 * 60; i++) update(DT);
ball.active = true; ball.collected = false; ball.bounces = 0; ball.scored = false;
ball.x = P.x + 0.12; ball.z = P.z; ball.y = 2.6; ball.vx = ball.vy = ball.vz = 0;
G.wasServe = true;
hitServe(P, 1, { sx, hard: 1 }); // flush timing, full pace, `sx` of slide
for (let i = 0; i < 5 * 60; i++) {
update(DT);
if (ball.lastHit === 'O') return true; // he got a racket on it
if (!ball.active || G.phase !== 'rally') break;
}
return false;
};
let got = 0;
const n = 10;
for (let i = 0; i < n; i++) if (serve(-0.5)) got++;
assert.ok(got >= n * 0.6, `opponent one returns a slid serve: ${got}/${n} (was 0)`);
});
test('the match racket is longer, and only the match racket', () => {
assert.ok(TUNE.matchReach > 1.2, 'match contact reach is widened — the recorder showed misses by centimetres');
// the gore modes' contact stays untouched: matchReach only multiplies inside G.match