Give the rally its length back: deep balls, baseliners, approaches off short balls

Every match had collapsed into serve, both at the net, reflex volleys at
~0.7 s per shot. The rally ball landed on the service line by definition,
the match CPU stood on the bounce and volleyed 90-100% of everything, and
the auto-run chased the earliest contact.

Rally modes only (classic + every match; gore and the duel untouched):
rally balls land deep and mistiming only shortens them (no new long outs);
each shot latches the receiver's plan as it leaves the racket — from the
back he lets it bounce and meets it falling, up to 2.2 m behind the line,
a short ball pulls him in and he follows it to the net, nobody approaches
a man already there, and a volley below the tape is a weak block. Measured:
contacts 10.5-13.7 m from the net, back-court volleys 0-8%, 0.8-1.0 s per
baseline shot. Three new tests pin the shape; v361.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5.5 committed 2026-10-13 12:55:05 +02:00
1 parent f33d46366a
commit 4e7c816a25
8 files changed
+292 -29

No files matched your search

+18
View File
@@ -74,6 +74,24 @@ export const TUNE = {
smashBoost: 0.42, // extra pace on a FORWARD flick (aim.az < 0), scaled by timing. It does not
// send the ball longer — solveShot still lands it on the same spot — it
// gets there flatter and sooner, which is what a smash is.
// THE COURT HAS ITS LENGTH (2026-10-13, PROJECT.md "THE RALLY GOES FLAT"). RALLY MODES ONLY
// (classic + every match). Gore's tennis prologue and the duel keep SHOT_DEPTH and HOME. The rally
// ball used to land ON the service line (SHOT_DEPTH.mid 5.4) and both sides met it as early as
// they could, so every point became a volley exchange at the T at ~0.7 s per shot.
rallyMid: 2.8, // m inside the receiver's baseline a plain rally ball aims for (gore: 5.4)
rallyDeep: 1.9, // ...and a forward flick (gore: 3.0). Mistiming only ever SHORTENS these,
// so depth adds no long out
shortK: 3.0, // m of depth a quality-0 ball loses (rolled x0.5..1.5): a mistimed ball
// sits up SHORT, which is the ball a real opponent attacks
shortBall: 4.9, // m from the receiver's baseline past which a landing is SHORT: he runs
// in and takes it on the rise (serves never count)
netFrom: 7.0, // m from the net inside which a body is AT THE NET: it volleys, and it
// recovers to netStand instead of the baseline
netStand: 3.2, // m from the net a net player recovers to
rallyBack: 0.6, // m behind the baseline a baseliner recovers to
rallyBehind: 2.2, // m behind the baseline he will drop back to let a deep ball come down
rallyHit: 1.0, // m: a baseliner meets the ball falling through this height
lowVolleyQ: 0.3, // quality cap on a volley struck below the tape: you can only block it up
aimError: 2.4, // m of scatter at quality 0
aimTrust: 0.3, // an aimed rally shot TRUSTS its flick: the scatter may still push the
// ball further OUT (the chosen out-risk keeps its exact shape) but only
+93 -16
View File
@@ -243,8 +243,14 @@ function doHit(ch, quality, aim) {
const bound = Math.max(-TUNE.flickWidth, Math.min(TUNE.flickWidth, ch.x));
tx = sway < 0 ? Math.min(tx, bound) : Math.max(tx, bound);
}
const tzBase = aim.az < 0 ? SHOT_DEPTH.deep : aim.az > 0 ? SHOT_DEPTH.short : SHOT_DEPTH.mid;
tz = (toFar ? tzBase : COURT.L - tzBase) + err() * 0.8;
// THE COURT HAS ITS LENGTH: in a rally mode the ball goes DEEP, and bad timing costs DEPTH — a
// mistimed ball sits up short, which is the ball a real opponent attacks — never length, so
// depth adds no out. The down-flick keeps its old short ball (it becomes the soft ball next).
const deep = rallyFeel() && aim.az <= 0;
const tzBase = aim.az < 0 ? (deep ? TUNE.rallyDeep : SHOT_DEPTH.deep)
: aim.az > 0 ? SHOT_DEPTH.short : deep ? TUNE.rallyMid : SHOT_DEPTH.mid;
const dz = deep ? (1 - quality) * TUNE.shortK * (0.5 + Math.random()) : err() * 0.8;
tz = deep ? (toFar ? tzBase + dz : COURT.L - tzBase - dz) : (toFar ? tzBase : COURT.L - tzBase) + dz;
// A FORWARD FLICK IS A SMASH. Up-screen is the far court, so flicking INTO the court asked for
// depth and nothing else — the gesture had a direction with no consequence behind it. It buys
// PACE now, scaled by timing like the width is. Note what it deliberately does not do: solveShot
@@ -315,7 +321,12 @@ function doHit(ch, quality, aim) {
}
ball.g = g;
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
ball.lastHit = ch.side; ball.bounces = 0;
// ...and a short ball taken is an APPROACH: he follows it in (rallyHome reads this while it flies).
// Never against a man already at the net — that ball is a PASS, and two volleyers is the old
// reflex duel at a quarter of a second per shot. ball.from is the striker's distance from the net.
ball.approach = rallyFeel() && ball.rx && ball.rx.short && !(ball.from < TUNE.netFrom) ? ch.side : null;
ball.from = Math.abs(ch.z - COURT.netZ);
ball.lastHit = ch.side; ball.bounces = 0; ball.rx = null; // a new shot: the receiver plans it afresh
G.wasServe = false; // a serve volleyed back pre-bounce must not fault-check the return
ball.pow = quality; // carried pace: zombies splatter only above zSplatQ
ball.kills = 0; // fresh shot: multi-kill streak restarts
@@ -531,7 +542,7 @@ export function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
perfect && G.mode === 'splatter' && !G.interlude && G.weapon === 0 && !G.temp);
ball.g = g;
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
ball.lastHit = sv.side; ball.bounces = 0;
ball.lastHit = sv.side; ball.bounces = 0; ball.rx = null; ball.approach = null; ball.from = Math.abs(sv.z - COURT.netZ);
ball.pow = quality;
ball.kills = 0; // fresh serve: multi-kill streak restarts
ball.praise = null; // ...and any word a spent ball never got to pop dies with it
@@ -1346,6 +1357,43 @@ export const winLevel = () => G.match && !G.net ? bonus().window * (1 + TUNE.pee
export const assistK = () => (rallyFeel() ? Math.max(TUNE.autoAssist, TUNE.matchAssist) : TUNE.autoAssist)
* legLevel();
// THE COURT HAS ITS LENGTH. How the receiver plays THIS shot, decided once as it leaves the racket
// (every strike clears ball.rx): at the net he volleys; from the back he lets it bounce, and a SHORT
// landing pulls him in to take it on the rise. Latched, so a man running in for a short ball does not
// turn volleyer the moment he crosses netFrom. Rally modes only — the callers gate it.
function rxPlan(ch) {
if (!ball.rx) {
const land = ball.bounces === 0 ? predictLanding() : null;
const depth = land ? (ch.side === 'P' ? COURT.L - land.z : land.z) : 0;
ball.rx = { net: Math.abs(ch.z - COURT.netZ) < TUNE.netFrom, short: !G.wasServe && depth > TUNE.shortBall };
}
return ball.rx;
}
// ...a baseliner lets a low ball bounce: only a man at the net takes it out of the air below the tape
const letBounce = () => ball.bounces === 0 && ball.y < COURT.netH && ball.rx && !ball.rx.net;
// ...and where he recovers to is where he IS: at the net he holds the net, otherwise he goes back
// behind his baseline. Read live — recovery always moves him away from netFrom, so it cannot dither.
// The one exception is the APPROACH: having taken a short ball, he follows it in.
const rallyHome = ch => ({ x: 0, z: Math.abs(ch.z - COURT.netZ) < TUNE.netFrom || ball.approach === ch.side
? COURT.netZ + (ch.side === 'P' ? TUNE.netStand : -TUNE.netStand)
: ch.side === 'P' ? COURT.L + TUNE.rallyBack : -TUNE.rallyBack });
// Where a baseliner meets this ball: after the bounce, falling through rallyHit, or at the top of a
// bounce that never gets that high. A SHORT ball is met on the RISE instead, moving in. Closed form
// off predictLanding, for racketAI (intercept walks the flight for itself).
function bounceContact(short) {
const g = ball.g || TUNE.gravity;
let { x, y, z, vx, vy, vz } = ball;
if (ball.bounces === 0) {
const p = predictLanding(); if (!p) return null;
x = p.x; z = p.z; y = 0;
vy = -(vy - g * p.t) * TUNE.bounceY; vx *= TUNE.bounceXZ; vz *= TUNE.bounceXZ;
}
const apex = y + Math.max(0, vy) ** 2 / (2 * g), h = Math.min(TUNE.rallyHit, apex);
const r = Math.sqrt(Math.max(0, vy * vy - 2 * g * (h - y)));
const t = Math.max(0, (short && vy > 0 ? vy - r : vy + r) / g);
return { x: x + vx * t, z: z + vz * t };
}
function intercept(side) {
const ch = side === 'O' ? O : P;
// G.diff is CLASSIC's difficulty chip (config.DIFFS) — null everywhere else, so only the
@@ -1354,11 +1402,22 @@ function intercept(side) {
let { x, y, z, vx, vy, vz } = ball;
const g = ball.g || TUNE.gravity, dt = 0.01;
const inZone = side === 'O' ? zz => zz <= ZONE_DEPTH : zz => zz >= COURT.L - ZONE_DEPTH;
const home = HOME[side];
let fallback = null, deepest = null, anyHit = null;
const home = rallyFeel() ? rallyHome(ch) : HOME[side];
const plan = rallyFeel() ? rxPlan(ch) : null, back = plan && !plan.net; // a baseliner lets it bounce
let fallback = null, deepest = null, anyHit = null, rise = null, nb = ball.bounces;
for (let i = 0; i < 300; i++) {
vy -= g * dt; x += vx * dt; y += vy * dt; z += vz * dt;
if (y <= 0 && vy < 0) { y = 0; vy = -vy * TUNE.bounceY; vx *= TUNE.bounceXZ; vz *= TUNE.bounceXZ; }
if (y <= 0 && vy < 0) { y = 0; vy = -vy * TUNE.bounceY; vx *= TUNE.bounceXZ; vz *= TUNE.bounceXZ; nb++; }
// FROM THE BACK: meet it between its bounce and the next one — at the top or falling through
// rallyHit+0.3, from as far as rallyBehind behind the line; a short ball the first moment it is
// up off the grass. Only if nothing like that is reachable do the old candidates below apply.
if (back && nb === 1 && i > 5 && y > 0.45 && y < TUNE.rallyHit + (plan.short ? 0.6 : 0.3)) {
const inB = side === 'P' ? z <= COURT.L + TUNE.rallyBehind : z >= -TUNE.rallyBehind;
if (inB && Math.hypot(x - ch.x, z - ch.z) / spd <= i * dt) {
if (plan.short || vy < 1) return { x, z };
rise ||= { x, z };
}
}
// THE SHORT BALL (match): a slow return can bounce and DIE at z 18-20, which never enters the
// near zone below — so nothing ever chased it, it rolled dead at the player's feet (below the
// legal contact height by then) and the point went to the zombie. Seven of seven recorded
@@ -1371,12 +1430,12 @@ function intercept(side) {
if (i > 5 && inZone(z) && y < 1.6 && y > 0.3) {
const pt = { x, z: side === 'O' ? Math.max(0.8, z) : Math.min(24.5, z) };
const reachable = Math.hypot(pt.x - ch.x, pt.z - ch.z) / spd <= i * dt;
if (y > 0.7 && reachable) return pt; // waist-high AND arrivable in time
if (y > 0.7 && reachable && !back) return pt; // waist-high AND arrivable in time
if (reachable && !fallback) fallback = pt; // low but arrivable
deepest = pt; // last resort: run at the deepest hittable point
}
}
return fallback || deepest || anyHit || home; // zone points first: meeting deep is still better
return rise || fallback || deepest || anyHit || home; // zone points first: meeting deep is still better
}
// ---------------------------------------------------------- miss diagnostics ----
@@ -1449,6 +1508,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
const dx = ball.x - ch.x, dz = ball.z - ch.z;
if (Math.hypot(dx, dz * 0.8) > reach || ball.y > CONTACT.maxY) return;
if (ball.y < CONTACT.minY && ball.vy <= 0) return; // a DROPPING ball below the ankles is past you — but a rising one is a half-volley, always scoopable
if (letBounce()) return; // planned from the back: the swing waits for the bounce instead of a weak pick-up
// quality: how centered in the window the contact lands
const center = TUNE.swingWindup + win / 2;
const off = Math.abs(t - center) / (win / 2);
@@ -1475,6 +1535,9 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) {
// luckiest returns. Classic and solo match traits own their bands; gore keeps its own floor.
const qMin = G.diff ? G.diff.qMin : G.match && !G.net ? (ch.foe?.qMin ?? TUNE.aiQualityMin) : TUNE.aiQualityMin;
if (isAI) q = Math.min(q, qMin + Math.random() * (Math.max(qCap, qMin) - qMin));
// a volley struck BELOW THE TAPE can only be blocked up: soft, short, little width. This is what
// the net costs — the passing shot dipped at your feet.
if (rallyFeel() && ball.bounces === 0 && ball.y < COURT.netH) q = Math.min(q, TUNE.lowVolleyQ);
if (!isAI && G.mode === 'tennis') {
G.swingSnaps = null; G.rallyNear = null;
logSwing({ result: 'HIT', reason: `contact q=${Math.round(q * 100) / 100}`, d: Math.round(Math.hypot(dx, dz * 0.8) * 100) / 100, y: Math.round(ball.y * 100) / 100 });
@@ -1521,11 +1584,25 @@ function racketAI(rz, dt) {
: G.match ? TUNE.aiRead
// the DUEL's read climbs with the run (never to 1 — wrong-footing stays possible)
: TUNE.aiRead + (TUNE.duelReadMax - TUNE.aiRead) * duelSkill();
const aimX = recovering ? HOME.O.x : pred ? ball.x + (pred.x - ball.x) * read : ball.x;
// THE COURT HAS ITS LENGTH (match only): from the back he waits BEHIND the bounce for the ball to
// come down — standing ON it made him volley 90-100% of everything from the T — and a short ball
// pulls him in. At the net he holds his depth on the ball's line, unless it will clear his racket:
// that is a lob, and he turns and runs it down like a baseliner.
const plan = G.match && incoming ? rxPlan(rz) : null;
if (plan && plan.net && ball.vz < 0) {
const tc = (rz.z - ball.z) / ball.vz, gg = ball.g || TUNE.gravity;
if (tc > 0 && ball.y + ball.vy * tc - 0.5 * gg * tc * tc > CONTACT.maxY) plan.net = false;
}
const c = plan && !plan.net ? bounceContact(plan.short) : null;
const netX = plan && plan.net ? ball.x + ball.vx * Math.max(0, (rz.z - ball.z) / ball.vz) : null;
const tgtX = c ? c.x : netX ?? (pred && pred.x);
const home = G.match ? rallyHome(rz) : HOME.O;
const aimX = recovering ? home.x : tgtX != null ? ball.x + (tgtX - ball.x) * read : ball.x;
// Read the landing depth immediately in a match, including rising returns. Blending with
// the ball's current depth would send every receiver towards the net before it arrives.
const aimZ = recovering ? HOME.O.z : pred ? (G.match ? pred.z : ball.z + (pred.z - ball.z) * read) : ball.z;
const tz = Math.max(1.5, Math.min(G.match ? COURT.netZ - 1.2 : COURT.svcFar, aimZ));
const aimZ = recovering ? home.z : c ? c.z : netX !== null ? rz.z
: pred ? (G.match ? pred.z : ball.z + (pred.z - ball.z) * read) : ball.z;
const tz = Math.max(G.match ? -TUNE.rallyBehind : 1.5, Math.min(G.match ? COURT.netZ - 1.2 : COURT.svcFar, aimZ));
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,
@@ -1559,7 +1636,7 @@ function racketAI(rz, dt) {
const wnd = G.match ? matchWound(rz) : { reach: 1, flub: 1 };
if (incoming && (!G.match || ball.z < COURT.netZ) && rz.swingCool <= 0 && rz.swingT < 0
&& Math.hypot(ball.x - rz.x, (ball.z - rz.z) * 0.8) < TUNE.aiReach * wnd.reach
&& ball.y < CONTACT.maxY
&& ball.y < CONTACT.maxY && !letBounce()
&& (G.match ? ball.y >= CONTACT.minY || ball.vy > 0 : ball.y > CONTACT.minY)) {
// ...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.
@@ -1976,7 +2053,7 @@ export function update(dt) {
// who never walked back.
const tgt = chasing ? intercept('P')
: G.mode === 'splatter' && !G.match ? { x: P.x, z: HOME.P.z }
: HOME.P;
: rallyFeel() ? rallyHome(P) : HOME.P;
const dx = tgt.x - P.x, dz = tgt.z - P.z, d = Math.hypot(dx, dz);
if (d > 0.35) { // deadzone: stand ready near the spot instead of dithering over it
P.x += dx / d * Math.min(rate, d);
@@ -2105,7 +2182,7 @@ export function update(dt) {
// the flight shortens the earliest-reachable point jumps around its solution — each jump
// flips his run direction, faster and faster. He commits to the stored point unless the
// fresh solve moves it meaningfully (or the chase resets between shots).
const fresh = incoming ? intercept('O') : HOME.O;
const fresh = incoming ? intercept('O') : rallyFeel() ? rallyHome(O) : HOME.O;
if (!incoming || !G.aiTgt || Math.hypot(fresh.x - G.aiTgt.x, fresh.z - G.aiTgt.z) > 0.35)
G.aiTgt = fresh;
const tgt = G.aiTgt;
@@ -2123,7 +2200,7 @@ export function update(dt) {
if (incoming && G.aiSwingCool <= 0 && O.swingT < 0) {
// same radius trySwing uses for contact, so an armed swing actually connects
const near = Math.hypot(ball.x - O.x, (ball.z - O.z) * 0.8) < TUNE.aiReach
&& ball.y < CONTACT.maxY && ball.y > CONTACT.minY && ball.vz < 0;
&& ball.y < CONTACT.maxY && ball.y > CONTACT.minY && ball.vz < 0 && !letBounce();
if (near) {
// rubber band: every game HE has taken makes him flub more — the opening match must not
// be a wall a new player grinds against for rounds (see aiFlubLoss in config).
+2 -1
View File
@@ -75,7 +75,7 @@ export function netSendShot() {
// the opponent struck: their ball, mirrored, becomes the live ball — and the authority flips here
export function netApplyShot(m) {
const s = mirror(m);
ball.active = true; ball.collected = false; ball.bounces = 0;
ball.active = true; ball.collected = false; ball.bounces = 0; ball.rx = null; ball.approach = null;
ball.x = s.x; ball.y = m.y; ball.z = s.z;
ball.vx = s.vx; ball.vy = m.vy; ball.vz = s.vz;
ball.g = m.g; ball.pow = m.pow;
@@ -84,6 +84,7 @@ export function netApplyShot(m) {
G.wasServe = !!m.serve; // arms the receiver's own service-box call
G.phase = 'rally'; G.swung = false; G.rallyNear = null;
O.x = s.x; O.z = s.z; // the strike happened where the ball says it did
ball.from = Math.abs(O.z - COURT.netZ); // ...which is what the receiver's approach rule reads
O.pose = m.serve ? 'serve' : 'swing'; O.poseT = 0;
// ...AND IT MAKES A NOISE (Oliver, 2026-09-11: "der ballsound des gegners wird nicht abgespielt").
// Every other opponent in the game is simulated locally and cracks through gameplay's own strike
+2 -2
View File
@@ -7,7 +7,7 @@ import { gore } from './zombies.js'; // runtime-only cycle (zombies imports stat
export const P = { x: 1.3, z: 22.8, side: 'P', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 };
export const O = { x: -1.3, z: 1.0, side: 'O', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 };
export const ball = { x: 0, y: 1, z: 20, vx: 0, vy: 0, vz: 0, g: 9.2,
active: false, lastHit: 'P', bounces: 0, collected: false, kills: 0, bomb: false, flame: false,
active: false, lastHit: 'P', bounces: 0, rx: null, approach: null, collected: false, kills: 0, bomb: false, flame: false,
// NETPLAY: THE CALLED BALL PLAYS OUT. A verdict used to freeze the ball where it was — on the
// striker's phone that is mid-roll, a whole court away from the call, and it read as the game
// loop stopping. `scored` means "this flight is decided": physics keeps running, every scoring
@@ -205,7 +205,7 @@ export const other = s => s === 'P' ? 'O' : 'P';
export const deuceSide = () => (score.pts[0] + score.pts[1]) % 2 === 0; // even points → deuce (right) side
export function setupPoint() {
G.phase = 'pretoss'; ball.active = false; ball.bounces = 0; ball.scored = false;
G.phase = 'pretoss'; ball.active = false; ball.bounces = 0; ball.scored = false; ball.rx = null; ball.approach = null;
G.swingSnaps = null; G.rallyNear = null; G.swung = false;
if (G.mode === 'splatter' && !G.match) { // always your serve, from wherever you're standing
// (the MATCH falls through to the tennis branch below: