Diagnose why every rally ends in a net duel

Oliver: every match converges on serve, both at the net, reflex volleys,
won by a wrong-foot. Measured headless: rally balls land on the service
line by definition, the match CPU stands on the bounce point and volleys
90-100% of everything from opponent 6 up, and the auto-run always picks
the earliest contact. Diagnosis, research and a four-step plan go into
PROJECT.md (awaiting his go); tools/rally-sim.mjs is the gauge. Fallback
tag rally-baseline-v360 marks the shipped feel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5.5 committed 2026-10-13 12:32:36 +02:00
1 parent 806a739c6f
commit f33d46366a
2 files changed
+247

No files matched your search

+91
View File
@@ -5127,10 +5127,101 @@ replay button"*. Both are netplay-only by construction, which is why neither had
→ both courts come up 0-0 with full blood, the friend standing, no gore, "REMATCH — YOUR SERVE"
against "REMATCH — THEIR SERVE". 146 tests.
## THE RALLY GOES FLAT — diagnosis (2026-10-13, UNDER DISCUSSION, nothing built)
Oliver: *"jedes Spiel läuft am Ende gleich ab (vor allem in höheren Levels): Aufschlag, dann schnell
vor ans Netz (Spieler und Opponent), von wo aus ein sehr schneller Ballwechsel passiert und durch eine
Täuschung des Gegners schließlich ein Punkt erzielt werden kann ... so läuft kein echtes Tennismatch
ab."* He suspected the late opponents' speed. Both modes, classic and zombie tennis. **Fallback tag
before any change: `rally-baseline-v360`** (806a739, pushed).
**Measured** with `node tools/rally-sim.mjs` (real modules, test.js's autopilot, 240 s per opponent;
the service line is 6.4 m from the net, the baseline 11.9 m):
| zombie tennis | P meets the ball (m from net) | P volleys | O meets the ball | O volleys | time per shot |
|---|---|---|---|---|---|
| opponent 1 | 4.9 | 87% | 9.3 | 56% | 0.53-0.67 s |
| opponent 6 | 7.7 | 52% | 7.6 | 90% | 0.63-0.72 s |
| opponent 12 | 7.8 | 50% | 7.7 | 99% | 0.72 s |
| opponent 18 | 8.5 | 7% | 6.6 | 100% | 0.72-0.75 s |
Every rally ball lands at a median **5.4 m from the baseline: on the service line**. Classic tennis
is milder but has the same shape (contact 8.5-10.8 m from the net, 0.75-1.0 s per shot, the same
5.4 m landings).
**Four structural causes, none of them a tuning value:**
1. **The rally ball lands on the service line by definition.** `SHOT_DEPTH.mid` is 5.4, i.e. the
service line itself. In real tennis that is a SHORT ball, an invitation to attack. `deep` (3.0) is
the only ball in the back third, and the CPU asks for it 25% of the time.
2. **The match opponent runs to the BOUNCE and takes the ball there.** `racketAI` aims at `pred.z`
(the first-bounce point, since v352's "approach as far as 1.2 m behind the net"), and is clamped at
`>= 1.5`, so it can never stand behind its own baseline. Standing on the bounce point, the ball
enters its 1.6 m reach before it lands, so from opponent 6 up it volleys 90-100% of everything
from the service line. The speed dials (`matchSpeedPer`, foe `speed`, `leaguePace`) only make it
arrive there sooner. That is what "they get very fast" feels like.
3. **The player's auto-run picks the EARLIEST reachable waist-high point.** `intercept('P')` walks
the flight forward in time and returns the first point that qualifies, which is always the one
nearest the net. At opponent 1, 72% of the player's contacts are inside the service line. The
classic CPU goes through the same `intercept`, which is why classic has the shape too.
4. **Every shot has the same shape.** `solveShot` at pace `s` to one of three depths. No height, no
spin family, no lob or drop for the CPU. The lane is the only variable left, so wrong-footing
(THE READ) is the only way a point can end. That is exactly his description.
**Prototype (scratch copy, not committed):** both sides take the ball AFTER the bounce, below 1.5 m
and falling, and may stand up to 2.5 m behind their baseline. Homes move behind the baseline (P 24.4,
O -0.6). The match CPU waits behind the bounce instead of on it. Rally depth is deep by default, and
mistiming lands the ball SHORTER, never longer, so it adds no outs. Result: contact 10-12 m from the
net on both sides, volleys 0-2%, 0.8-0.97 s per shot. Rallies at the high rungs then never end for
the bot. That is expected: once the geometry is fixed, what is missing is the tools that END a point.
**What the research says** (real tennis and other tennis games, sources in the session's brief):
- Real points are mostly SHORT. 0-4 shots make up ~70% of men's and ~66% of women's points, 5-8 ~20%, 9+ ~10%
(AO 2015, Wimbledon 2021). Variety is lots of short points plus a long-rally tail, not long rallies
everywhere.
- Pros win 66-69% at the net BECAUSE they come in selectively. Controlled for the situation,
serve-and-volley is worse than staying back: 66.8% vs 72.2% (Tennis Abstract). They approach "only when
the point has developed in an inviting way", i.e. off a short ball. The net is made expensive by the
passing shot dipped at the feet, the lob, and needing a good approach first. Our game has none of those
three costs.
- Even the most minimal arcade tennis games (Super Tennis, Virtua Tennis DC, Mario Tennis) give the lob
its own input. Wii Sports rejected reading the swing's direction because "the ball doesn't fly in the
direction you expect", which supports our one-gesture rule. Shape-coded swipes (VT Tennis mobile) fight
with direction.
- AI that is "just fast" is the genre's known failure. Matchpoint's career mode reportedly needs constant
net rushing. What works is style dials (TopSpin's baseline/serve-volley/defense/all-round spreads) and
believable over/under-hits. Free Tennis's AI "learns whether it is best to take the net or stay back".
**The plan put to him (smallest root first; every number a TUNE knob, the tag is the fallback):**
1. **THE COURT GETS ITS LENGTH BACK.** No new input, both modes. Rally balls land deep, and mistiming lands
them SHORT (never long, no new outs). Both sides take the ball after the bounce from behind the
baseline. Coming forward happens only onto a short ball: the auto-run runs you in, and you are then
visibly at the net, where you can be passed or lobbed. A volley below net height can only be blocked up
soft. The suite gets a rally-shape test (contact depth, volley share, shots per point).
2. **OPPONENTS WITH A GAME.** The CPU gains shot shapes (heavy topspin that kicks high and pushes you
back, low skidding slice, moonball, lob, drop shot) and per-row STYLE dials on `OPPONENTS`: home depth,
approach threshold, lob/drop rate, spin. The roster lines already read as styles: pushers at the bottom,
ROLLER/BRUTE flat hitters, BALL BOY/RUNNER retrievers, CLUB PRO/DIRECTOR serve-and-volley,
MASCOT/PHYSIO touch players, CHAMPION all-court, and he learns whether the net is working. The ladder
then climbs through style and consistency rather than legs, and the speed dials are re-tuned
afterwards. Context-chosen shots are fine for the CPU: the rule binds the player's gesture, and a lob's
arc announces itself.
3. **THE PLAYER'S SOFT BALL (Oliver's call).** The down-flick today means "short", which is a fast short
ball nobody needs. Proposal: down = SOFT, and its length = how far, so short = drop shot, long = lob.
One meaning in every situation, no new gesture. This is NOT the context-armed lob that was cut on
2026-08-22. Rejected alternative: coding lob/drive by flick SPEED (AO Tennis Smash, Tennis Clash). A
right thumb's left swipe is naturally ~2x slower (measured 2026-08-20), so every left flick would turn
into a lob.
4. **Retune the escalation** against the new shape, with `tools/rally-sim.mjs` and the step-1 test as the
gauge.
# OPEN — leftovers, in rough priority order
Nothing here is broken; these are known gaps and unverified risks.
0. **THE RALLY GOES FLAT (2026-10-13), the top item.** Every match converges on serve, both at the net,
reflex volleys, won by a wrong-foot. Diagnosed and measured; a four-step plan is awaiting Oliver's go.
See the section of that name just above. Fallback tag `rally-baseline-v360`.
1. **`RES` 4 is unverified on a real phone under load.** It nearly doubled the fill rate (810 -> 1080
device px across). Oliver has not reported stutter, but he also has not played a late horde since.
**If the game ever stutters, `RES` back to 3 is the first move** — not reducing zombie counts.
+156
View File
@@ -0,0 +1,156 @@
// Rally geometry probe: `node tools/rally-sim.mjs` (RUNGS=1,6,12,18 SECS=240, CLASSIC=1 RUNGS=0,1,2 for
// the three classic difficulties). Plays the real modules headless with test.js's autopilot (auto-run,
// a fixed tap window, a random lane/deep flick) and prints WHERE each side meets the ball, how often
// it volleys, how long each shot gives the other side, and where rally balls land. It measures the
// shape of a rally, not a win rate — the bot is not Oliver. See PROJECT.md "THE RALLY GOES FLAT".
// ponytail: the platform stubs are copied from test.js; fold this into a test once the rework lands.
let _seed = (Number(process.env.SEED) || 20260811) >>> 0;
Math.random = () => { // xorshift32 — small, fast, good enough for jitter
_seed ^= _seed << 13; _seed >>>= 0;
_seed ^= _seed >> 17;
_seed ^= _seed << 5; _seed >>>= 0;
return _seed / 4294967296;
};
// ---- platform stubs, installed before the game modules load ----------------
const absorb = new Proxy(function () {}, {
get: (t, p) => (p === Symbol.toPrimitive ? () => 0 : absorb),
set: () => true,
apply: () => absorb,
construct: () => absorb,
});
const fakeCtx = () => new Proxy({}, {
get: (t, p) => {
if (p === 'canvas') return fakeCanvas();
if (p === 'measureText') return () => ({ width: 0 });
if (p === 'getImageData') return (x, y, w, h) => ({ data: new Uint8ClampedArray(Math.max(4, w * h * 4)), width: w, height: h });
if (p === 'createLinearGradient' || p === 'createRadialGradient') return () => ({ addColorStop() {} });
return () => undefined;
},
set: () => true,
});
function fakeCanvas() {
return {
width: 270, height: 480, style: {},
getContext: () => fakeCtx(),
addEventListener() {}, removeEventListener() {},
getBoundingClientRect: () => ({ left: 0, top: 0, width: 270, height: 480 }),
toDataURL: () => '',
};
}
const store = new Map();
globalThis.localStorage = {
getItem: k => (store.has(k) ? store.get(k) : null),
setItem: (k, v) => store.set(k, String(v)),
removeItem: k => store.delete(k),
};
globalThis.document = {
getElementById: () => fakeCanvas(),
createElement: () => fakeCanvas(),
addEventListener() {}, removeEventListener() {},
body: { style: {}, appendChild() {} },
};
globalThis.window = globalThis; // audio.js reads window.AudioContext; render reads window.visualViewport (optional)
globalThis.AudioContext = function () { return absorb; };
globalThis.webkitAudioContext = globalThis.AudioContext;
globalThis.matchMedia = () => ({ matches: false, addEventListener() {} });
// haptics.js falls back to navigator.vibrate when there is no Capacitor bridge. Node ships a REAL
// navigator and it is getter-only, so the stub has to be defined over it rather than assigned.
globalThis.hapticLog = [];
Object.defineProperty(globalThis, 'navigator', {
configurable: true, value: { vibrate: ms => globalThis.hapticLog.push(ms) } });
globalThis.addEventListener = globalThis.addEventListener || (() => {});
globalThis.removeEventListener = globalThis.removeEventListener || (() => {});
globalThis.Image = class { constructor() { this.complete = false; } addEventListener() {} set src(v) {} };
globalThis.fetch = () => new Promise(() => {}); // audio assets: pending forever, never rejecting
globalThis.requestAnimationFrame = () => 0;
globalThis.devicePixelRatio = 2;
globalThis.innerWidth = 390; globalThis.innerHeight = 844;
globalThis.ResizeObserver = class { observe() {} disconnect() {} };
globalThis.getComputedStyle = () => ({ paddingTop: '0' });
const R = new URL('../src/', import.meta.url).href;
const state = await import(R + 'state.js');
const { G, P, ball, score } = state;
const gp = await import(R + 'gameplay.js');
const { update, keyPressed, restartRun } = gp;
const beats = await import(R + 'beats.js');
const { zombies } = await import(R + 'zombies.js');
const { TUNE, COURT } = await import(R + 'config.js');
const prof = await import(R + 'profile.js');
const DT = 1 / 60;
const RUNGS = (process.env.RUNGS || '1,6,12,18').split(',').map(Number);
const SECS = Number(process.env.SECS || 240);
TUNE.autoAssist = 1.15;
const pct = (a, f) => a.length ? Math.round(100 * a.filter(f).length / a.length) : 0;
const med = a => { const s = [...a].sort((x, y) => x - y); return s.length ? s[Math.floor(s.length / 2)] : NaN; };
const rows = [], land = [];
for (const rung of RUNGS) {
restartRun();
prof.prof.lg = 5; prof.prof.xp = Number(process.env.XP || 0); prof.takePromo();
G.mode = 'splatter'; G.everTransitioned = true; state.setupPoint();
const cfgm = await import(R + 'config.js');
if (process.env.CLASSIC) { G.mode = 'tennis'; G.everTransitioned = false; G.classic = true; G.diff = cfgm.DIFFS[rung]; state.setupPoint(); }
else beats.startMatch(false, null, rung);
G.modal = null; G.modalQ.length = 0;
for (let i = 0; i < 2.2 * 60; i++) { G.modal = null; G.modalQ.length = 0; update(DT); }
const lands = [];
const speeds = [];
const hits = []; // {side, z(dist from own baseline), bounced, t}
let prevLast = ball.lastHit, prevB = ball.bounces, rally = 0, rallies = [], lastHitT = null, t = 0;
let swipe = null;
for (let i = 0; i < SECS * 60; i++) {
G.modal = null; G.modalQ.length = 0;
if (G.mode === 'eaten' || G.done) { G.hp = G.hpMax; }
t += DT;
if (G.phase === 'pretoss' && !(G.interlude && G.interlude.introT > 0)) { if (i % 40 === 0) keyPressed('Space'); }
else if (G.phase === 'toss') { if (ball.vy < 0.4 && ball.y > 2.4) keyPressed('Space'); }
else if (G.phase === 'rally' && ball.active && ball.lastHit === 'O') {
const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8);
if (d < 2.9 && d > 2.2 && P.swingT < 0) {
const lane = [-1, 0, 1][Math.floor(Math.random() * 3)], mag = 40 + Math.random() * 80;
const fy = Math.random() < 0.4 ? -0.6 : 0, fx = lane * 0.8;
const n = Math.hypot(fx, fy) || 1;
swipe = lane || fy ? { ax: lane, az: fy < 0 ? -1 : 0, fx: fx / n, fy: fy / n, mag } : null;
G.swipe = swipe;
keyPressed('Space');
}
}
update(DT);
if (ball.lastHit !== 'O') G.swipe = null;
const rz = process.env.CLASSIC ? state.O : zombies.find(z => z.variant === 'r');
if (ball.active && ball.lastHit !== prevLast && (ball.lastHit === 'P' || ball.lastHit === 'O')) {
const ch = ball.lastHit === 'P' ? P : rz;
if (ch && !G.wasServe) {
const fromNet = Math.abs(ch.z - COURT.netZ);
speeds.push({ side: ball.lastHit, v: Math.hypot(ball.vx, ball.vz) });
hits.push({ side: ball.lastHit, fromNet, bounced: prevB > 0, dt: lastHitT === null ? null : t - lastHitT, y: ball.y });
}
lastHitT = t; rally++;
}
if (G.phase !== 'rally' && rally) { rallies.push(rally); rally = 0; lastHitT = null; }
if (ball.active && prevB === 0 && ball.bounces === 1 && !G.wasServe) {
const fromBase = ball.lastHit === 'P' ? ball.z : COURT.L - ball.z;
lands.push({ side: ball.lastHit, fromBase });
}
prevLast = ball.active ? ball.lastHit : null; prevB = ball.bounces;
}
for (const side of ['P', 'O']) {
const h = hits.filter(x => x.side === side);
rows.push({ rung, side, shots: h.length,
'medDistNet': +med(h.map(x => x.fromNet)).toFixed(1),
'<6.4m(svcline)%': pct(h, x => x.fromNet < 6.4),
'<3.5m(net)%': pct(h, x => x.fromNet < 3.5),
'volley%': pct(h, x => !x.bounced),
'medTimeToHit_s': +med(h.filter(x => x.dt).map(x => x.dt)).toFixed(2),
});
}
for (const side of ['P', 'O']) {
const l = lands.filter(x => x.side === side).map(x => x.fromBase);
const v = speeds.filter(x => x.side === side).map(x => x.v);
land.push({ rung, hitter: side, bounces: l.length, medLandFromBaseline: +med(l).toFixed(1),
'deep<3m%': pct(l, x => x < 3), 'svcbox>5.5m%': pct(l, x => x > 5.5), medPace: +med(v).toFixed(1) });
}
rows.push({ rung, side: 'rally', shots: rallies.length, medDistNet: med(rallies), '<6.4m(svcline)%': '', '<3.5m(net)%': '', 'volley%': '', medTimeToHit_s: 'shots/pt mean ' + (rallies.reduce((a, b) => a + b, 0) / rallies.length).toFixed(1) });
}
console.table(rows); console.table(land);