The serve gets stakes: tight apex window, depth rides quality, the strike flick aims

Three mechanical causes of 'no serve agency', measured then fixed:
- the 0.12 m perfect snap was ~0.3 s of hang time (now TUNE.apexSnap 0.05,
  and the gold band reads the same knob);
- the fixed mid-box target depth capped every good serve at ~36 m/s in the
  loft loop — player serve depth now rides quality, flush aims 0.3 m inside
  the service line (ladder: 49 / 52 hard / 36 mistimed / 30 bad);
- the strike flick was splatter-only — in tennis and the duel it now sways
  placement (horizontal) and buys pace (vertical, serveHard + serveHardSpin,
  the extra dive IS the clearance), paid for in scatter against the real
  fault machinery. Tap stays the safe second serve.

Phone verdict pending — see the PROJECT.md session log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-13 17:23:07 +02:00
1 parent c084d53497
commit de77ccfea5
6 files changed
+112 -20

No files matched your search

+22
View File
@@ -434,6 +434,28 @@ vetoed (*"the camera movement is distracting"*) and its knobs sit at 0 — see t
(~0.35ms/frame desktop, settled key at both ends) — if the phone stutters in rallies, zero these
two before touching anything else.
## Session log: the serve gets stakes (2026-08-13, same day)
Oliver: *"the golden mark is just very easy to hit and hitting below it does not necessarily make a
worse serve... the flicking does not work here — also I see no option to serve harder."* Three
mechanical causes found, all fixed; PHONE VERDICT PENDING:
1. **The perfect window was ~0.3 s of hang time.** The 0.12 m snap around the apex, at toss
gravity, held PERFECT for most of the ball's float. Now `TUNE.apexSnap` 0.05 / `apexFall` 0.55,
and the gold band on the apex guide reads the same knob, so the picture tightened with it.
2. **The pace ceiling was the TARGET DEPTH, not the timing.** Measured with a replica of
serveArc's loft loop: at the old fixed mid-box depth, every serve above q≈0.7 was cut to the
same ~36 m/s — quality could not show. Player serve depth now rides quality (flush aims 0.3 m
inside the service line, the only depth where flat pace survives). Ladder: flush 49 /
flush+hard 52 / mistimed 36 / bad 30 m/s. Opponent depth untouched.
3. **The strike flick was splatter-only.** In tennis and the duel the strike gesture now aims:
horizontal component sways the target toward T or sideline (`serveAimX`), upward component is
pace (`serveHard`, with `serveHardSpin` — the extra dive is what buys the clearance; without it
the loft loop cut the hard serve BELOW the plain one). Pace and sway are paid for in scatter
(`serveHardErr`) against the real fault machinery — tap stays the safe second serve. Keyboard:
arrows sway, up is the hard one. Verified in-pane with synthetic flicks: 49.4 / 52.5 / 51.0
swayed +1.1 m, all legal at flush timing.
## Session log: nothing arrives unannounced (2026-08-11, same day)
Oliver's retest: *"the horde is not announced anymore"* and *"bosses are just there now"*. Both were
+1 -1
View File
@@ -431,6 +431,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
</div>
</div>
</div>
<script type="module" src="src/main.js?v=212"></script>
<script type="module" src="src/main.js?v=213"></script>
</body>
</html>
+13
View File
@@ -28,6 +28,19 @@ export const TUNE = {
// which is the "harder pull, faster ball" relationship, and it is in serveQualitySpeed's share
// of the pair. (baseShotSpeed is the rally volley — shared with tennis and the CPU.)
serveSpeed: 22.8, serveQualitySpeed: 26.4,
// THE TOSS STRIKE HAS REAL STAKES NOW (Oliver: "the golden mark is very easy to hit and hitting
// below it does not necessarily make a worse serve... no option to serve harder").
apexSnap: 0.05, // m of |ball - apex| that still counts flush (was a hardcoded 0.12 — at toss
// gravity that was ~0.3 s of PERFECT, which is why the mark felt free)
apexFall: 0.55, // m of miss at which quality hits the floor (was 0.9) — mistimes cost pace
// ...and the strike gesture AIMS it: a flick as you hit sways the target and buys pace.
serveAimX: 1.8, // m of sway a full flick puts on the target — centre box to the T or the line
serveHard: 9, // m/s of extra pace at full flick strength — the harder first serve
serveHardSpin: 0.45, // extra dip the hard serve carries. NOT free style: without it the loft
// loop cut the +9 m/s BELOW a plain flush serve (measured 47.2 vs 49.3) —
// the dive is what buys the pace its net clearance
serveHardErr: 1.6,// how much a hard serve multiplies the timing scatter: pace is paid for in
// faults, which is what makes tap = safe second serve a real decision
serveSpin: 1.3, // extra gravity a full-power serve carries (was a fixed 0.35). Without it the
// net-clearance loop ate the whole smash: a flat fast ball can't get over the
// tape AND come down on a target, so it got throttled back to walking pace.
+51 -14
View File
@@ -283,9 +283,12 @@ function splatServeTarget(sv, aim, s, x0, z0, ex = 0) {
// The launch itself: topspin gravity, then trade pace for arc until the net is genuinely cleared.
// A shambler just the other side of the tape is only reachable on a loft.
function serveArc(x0, y0, z0, tx, tz, quality, ty = 0.08) {
let s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed;
const g = TUNE.gravity * (1 + quality * TUNE.serveSpin); // the harder you hit it, the harder it dips
function serveArc(x0, y0, z0, tx, tz, quality, ty = 0.08, hard = 0) { // hard: the aimed strike's 0..1
let s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed + hard * TUNE.serveHard;
// the harder you hit it, the harder it dips — and the HARD serve dives harder still
// (serveHardSpin), which is what buys its net clearance: MEASURED with the replica solver,
// +9 m/s with the old dip was cut BELOW the plain flush by the loft loop (47.2 vs 49.3).
const g = TUNE.gravity * (1 + quality * TUNE.serveSpin + hard * TUNE.serveHardSpin);
let v = solveShot(x0, y0, z0, tx, tz, s, g, ty);
// THERE IS ONLY A NET TO CLEAR IF THE SHOT CROSSES IT. A shambler on your own side sits short of
// the tape, so the ball never reaches that plane — but `tN` is still positive (the net is ahead
@@ -359,7 +362,15 @@ export function sliceCarom(tgt, speed) {
// 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) {
const toFar = sv.side === 'P';
const s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed;
// {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
// scatter. Nothing clamps the swayed target back inside the lines: aiming for the sideline
// with a mistimed toss is how a real first serve goes long, and faults already have their
// machinery (serveNum, the line call). A plain tap keeps the old safe centre-box serve.
const sx = aim && aim.sx !== undefined ? aim.sx : 0;
const hard = (aim && aim.hard) || 0;
const pace = hard * TUNE.serveHard;
const s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed + pace;
// splatServeTarget + serveArc are shared with previewServe, so the arc drawn under your thumb
// is the one the ball flies. Only the aim error is added here and left out of the preview.
const st = splatServeTarget(sv, aim, s, ball.x, ball.z, (Math.random() * 2 - 1) * (1 - quality) * 0.8);
@@ -367,11 +378,21 @@ function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
if (st) { boxX = st.tx; tzs = st.tz; }
else {
// target: diagonal service box. Serving from deuce side → target box is across.
boxX = (deuceSide() ? -1 : 1) * (toFar ? 1 : -1) * 2.0 + (Math.random() * 2 - 1) * TUNE.aimError * 0.8 * (1 - quality);
const boxZ = toFar ? COURT.svcFar + 1.6 : COURT.svcNear - 1.6;
tzs = boxZ + (Math.random() * 2 - 1) * (1 - quality) * 1.5;
const scatter = (0.8 + hard * TUNE.serveHardErr) * (1 - quality);
boxX = (deuceSide() ? -1 : 1) * (toFar ? 1 : -1) * 2.0 + sx * TUNE.serveAimX
+ (Math.random() * 2 - 1) * TUNE.aimError * scatter;
// DEPTH RIDES QUALITY (player serves only): a flush strike aims near the service line, the
// ONLY depth where flat pace survives the net-clearance loop — MEASURED with the replica
// solver: at the old fixed mid-box depth every serve above q≈0.7 was cut to the same
// ~36 m/s, which is the mechanical root of "hitting below the golden mark does not
// necessarily make a worse serve". The ladder now reads flush 49 / flush+hard 52 /
// mistimed 36 / bad 30. The 0.3 m floor inside the line is what scatter faults against.
// The opponent keeps its fixed tuned depth — its ladder is dialled elsewhere.
const boxZ = toFar ? COURT.svcFar + Math.max(0.3, 1.6 - quality * 1.35 - hard * 0.5)
: COURT.svcNear - 1.6;
tzs = boxZ + (Math.random() * 2 - 1) * scatter * 1.5;
}
const { v, g } = serveArc(ball.x, ball.y, ball.z, boxX, tzs, quality, st ? st.ty : 0.08);
const { v, g } = serveArc(ball.x, ball.y, ball.z, boxX, tzs, quality, st ? st.ty : 0.08, hard);
// THE BALL ERASES ITS OWN AIM. Cutting the band at the instant of release made a fast rally
// strobe — band, gone, band, gone. Instead the arc stays and the ball eats it from behind as it
// flies, so the aim resolves into the shot rather than blinking out of existence. This is the
@@ -1253,16 +1274,32 @@ export function keyPressed(code) {
tossBall(P); return; // plain tap: the two-stage toss ritual (tennis, and the duel)
}
if (G.phase === 'toss' && score.server === 'P') {
// serve timing: distance from apex = quality — a real serve, not a free tap
// serve timing: distance from apex = quality — a real serve, not a free tap. The snap window
// and falloff are TUNE knobs now (apexSnap/apexFall) and deliberately tight: the old 0.12 m
// snap held PERFECT for ~0.3 s of hang time, which is why the gold mark felt free.
const dy = Math.abs(ball.y - serveApexY());
if (ball.y < 2.0) return; // too low, let it drop (re-toss)
let q = Math.max(0.05, 1 - dy / 0.9);
if (dy < 0.12) q = 1;
let q = Math.max(0.05, 1 - dy / TUNE.apexFall);
if (dy < TUNE.apexSnap) q = 1;
G.wasServe = true;
// a flick at the apex (or held arrows) makes this a DELIBERATE serve: aimed + consumes a special
const flick = G.mode === 'splatter' && !G.interlude ? takeFlickAim() : null;
const flick = takeFlickAim();
if (G.mode === 'splatter' && !G.interlude) {
// splatter: a flick at the apex is the aimed free-aim serve, exactly as before
const kb = { ax: axisX(), az: axisZ() };
hitServe(P, q, flick || (kb.ax || kb.az ? kb : null));
return;
}
// TENNIS AND THE DUEL: the strike gesture IS the aim (Oliver: "the flicking does not work
// here - also i see no option to serve harder"). Flick as you strike: the HORIZONTAL
// component sways the serve toward the T or the sideline, the UPWARD component is pace —
// swing through the ball to hit it harder. Both scale with the flick's length (fx/fy are
// unit components; mag carries the length). Keyboard: left/right sway, up = the hard one.
// A plain tap stays the safe centre-box serve — that is the second-serve decision.
const kb = { ax: axisX(), az: axisZ() };
hitServe(P, q, flick || (kb.ax || kb.az ? kb : null));
const k = flick ? Math.max(0, Math.min(1, (flick.mag - 24) / 130)) : 0;
const sway = flick ? flick.fx * k : kb.ax * 0.75;
const hard = flick ? Math.max(0, -flick.fy) * k : (kb.az < 0 ? 0.8 : 0);
hitServe(P, q, sway || hard ? { sx: sway, hard } : null);
return;
}
if (G.phase === 'rally' && P.swingT < 0) { P.swingT = 0; P.pose = 'swing'; P.poseT = 0; G.swung = true; }
+4 -4
View File
@@ -865,9 +865,9 @@ function drawApproachRing() {
}
// THE APEX BAND (the serve tap): a thin guide beside the tossed ball with a gold band spanning the
// perfect window (serveApexY +- the same 0.12 m keyPressed scores as perfect) and a wider white
// zone for the playable one. A tick rides the live ball height. The ghost hand MIMES this moment;
// the band makes it a target you can see coming.
// perfect window (serveApexY ± TUNE.apexSnap — the SAME number keyPressed scores as perfect, so
// tightening the mechanic tightens the picture) and a wider white zone for the playable one. A
// tick rides the live ball height. The band makes the strike a target you can see coming.
function drawApexBand() {
if (!(G.match || G.mode === 'tennis') || G.phase !== 'toss' || score.server !== 'P') return;
const gx = ball.x + 0.55; // beside the toss, out of the ball's way
@@ -878,7 +878,7 @@ function drawApexBand() {
ctx.lineWidth = 1;
ctx.strokeStyle = 'rgba(255,255,255,0.35)'; // the guide
ctx.beginPath(); ctx.moveTo(x, yTop.y); ctx.lineTo(x, yBot.y); ctx.stroke();
const bTop = proj(gx, apex + 0.12, ball.z).y, bBot = proj(gx, apex - 0.12, ball.z).y;
const bTop = proj(gx, apex + TUNE.apexSnap, ball.z).y, bBot = proj(gx, apex - TUNE.apexSnap, ball.z).y;
ctx.fillStyle = hexA(UI.warm, 0.8); // the perfect window, gold
ctx.fillRect(x - 3, Math.round(bTop), 6, Math.max(2, Math.round(bBot - bTop)));
const tick = proj(gx, ball.y, ball.z).y; // where the ball is NOW
+21 -1
View File
@@ -83,7 +83,7 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' });
// ---- game modules (real code) ----------------------------------------------
const { G, P, O, score, ball, setupPoint } = await import('./src/state.js');
const { update, forceZombies, splatPoint, restartRun, flickTarget, keyPressed, classicGame } = await import('./src/gameplay.js');
const { update, forceZombies, splatPoint, restartRun, flickTarget, keyPressed, classicGame, serveApexY } = await import('./src/gameplay.js');
const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN } = await import('./src/beats.js');
// 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
@@ -938,6 +938,26 @@ test('classic tennis: a game is not a set, and six with a two-game lead is', ()
assert.equal(G.done, null, 'one set is not a match');
});
test('the toss strike has stakes: flush is fast, a miss is slow, the old free window is gone', () => {
// the serve-agency pass: apexSnap/apexFall are TUNE knobs and deliberately tight, so timing
// must show up in the ball's actual pace (the fault economy then prices the aimed serve)
G.mode = 'tennis'; G.classic = true; G.match = null; G.interlude = false; G.done = null;
const strike = dy => {
G.phase = 'pretoss'; ball.active = false; score.server = 'P'; P.pose = 'idle';
keyPressed('Space'); // toss
assert.equal(G.phase, 'toss', 'the tap tossed');
ball.y = serveApexY() - dy; ball.vy = 0.1; // park the ball dy below the apex
keyPressed('Space'); // strike
assert.equal(G.phase, 'rally', 'the strike fired');
return Math.hypot(ball.vx, ball.vy, ball.vz);
};
const flush = strike(0);
const oldFree = strike(0.1); // inside the old 0.12 snap, outside the new one
const missed = strike(0.4);
assert.ok(flush > missed * 1.2, `a flush strike is materially faster (${flush.toFixed(1)} vs ${missed.toFixed(1)})`);
assert.ok(oldFree < flush - 0.5, 'the 0.12 m free-perfect window no longer exists');
});
test('classic tennis: the second set ends the run, with no corpse', () => {
G.mode = 'tennis'; G.classic = true;
G.sets = [1, 0]; score.games = [6, 0]; G.runGames = 14;