// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update. import { TUNE, UI, bossArt, STORY, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS, racketStage, matchWound, STYLES, LADDER, LEAGUES, leagueOfRung, foeLevel, TIN_LEAGUE, TIN_DOUBLE_LEAGUE } from './config.js'; import { P, O, ball, score, crowd, G, court, tour, other, deuceSide, setupPoint, sendBoy, resetState } from './state.js'; import { audio, pock, swish, whoosh, applause, voice, voiceSeq, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } from './audio.js'; import { haptic } from './haptics.js'; import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js'; import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies import { updateDrill } from './drill.js'; import { endRun, beginRun, MODE_CLASSIC } from './stats.js'; import { award, beginRunXp, prof, bonus, levelAt } from './profile.js'; import { recordEvent, recordFrame } from './recorder.js'; import { updateZombies, updateParts, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel , fireBeam , pops, popText } from './zombies.js'; import { proceedAfterClear, startDuel, startHorde, warnHorde, resolveInterlude, watchdog, updateBench, benchKey, tickMatchFall, placeReceiver, tickStory } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing import { netSendShot, netSendToss, netTick, netJudging, netSendPoint, netSendFault, netClose } from './net.js'; // runtime-only cycle: net calls back into pointTo/fault/tossBall/showModal // ------------------------------------------------------------- ballistics ---- // Launch ball from (x0,y0,z0) so it arrives at (tx, tz) with horizontal speed s under gravity g. // `ty` is the height it arrives AT — ball-on-the-grass by default, but a splatter serve aims at a // shambler's chest, and solving to the ground under one is what made the ball strike the ankles. // The recorded racket sample, pitched by the pace of the ball that just left it — 20 m/s of rally // dink at 0.93, a 52 m/s serve at 1.09. Deliberately narrow: past about a tenth either way the // sample stops sounding like the same racket. Read off the LIVE ball, so it is one call site's // worth of code for every strike in the game. export const strikeRate = () => 0.93 + Math.max(0, Math.min(0.16, (Math.hypot(ball.vx, ball.vz) - 20) / 200)); // ...and a fast ball KICKS THE CAMERA as it leaves. The third answer to the same report: the trail // says how fast the ball is, the crack says how hard it was hit, and this says it in the one channel // the striker cannot miss, because it moves the whole picture rather than something inside it. // YOUR racket only (the far side's strike is something you watch — the same rule the haptic keeps), // nothing under 26 m/s, and never over a shake that is already running: a bomb outranks a backhand. const strikeKick = () => { const p = Math.min(2.2, (Math.hypot(ball.vx, ball.vz) - 26) / 9); if (p > 0 && !G.shake) G.shake = { t: 0.12, mag: p * 2.92 }; // mag*t/0.35 = the pixels it starts at }; // How much of its horizontal pace a ball keeps through a bounce: the court's own number, times the // shot's SPIN (ball.spin, 1 = none). Only the soft ball sets it — a drop carries backspin and dies. // Every planner that rolls a bounce forward reads this, or it predicts a ball that is not there. const bXZ = () => TUNE.bounceXZ * (ball.spin || 1); // ...and backspin keeps it LOW as well: the vertical rebound gives up part of what the pace does const bY = () => TUNE.bounceY * (1 - TUNE.spinLow * (1 - (ball.spin || 1))); // THE DIP ENDS AT THE BOUNCE (2026-10-14, Oliver's pick). A shot's extra gravity (quality topspin, // the serve's spin) is its DIVE IN FLIGHT — it was carried through the bounce, so a "heavy topspin" // ball skidded away LOW afterwards and nearly every contact in his logs was 0-25 cm off the grass. // Real topspin kicks UP. After the bounce a ball falls under plain gravity; the kick itself is // bY/bXZ's spin factor. Tennis modes only — gore's balls keep theirs. Every planner rolling a bounce // forward switches here too (intercept, bounceContact, ringCue, matchOutOfReachFlight) or it would // predict the old low ball. const gAfter = g => (rallyFeel() || G.mode === 'tennis' ? TUNE.gravity : g); export function solveShot(x0, y0, z0, tx, tz, s, g = TUNE.gravity, ty = 0.08) { const dx = tx - x0, dz = tz - z0, dist = Math.hypot(dx, dz), T = dist / s; return { vx: dx / T, vz: dz / T, vy: (ty - y0 + 0.5 * g * T * T) / T }; } export function predictLanding() { // first-bounce point, closed form — exact, so the ring never jitters const g = ball.g || TUNE.gravity; const disc = ball.vy * ball.vy + 2 * g * ball.y; if (disc < 0) return null; const t = (ball.vy + Math.sqrt(disc)) / g; // time until y = 0 return { x: ball.x + ball.vx * t, z: ball.z + ball.vz * t, t }; // t rides along: the landing ring contracts on it } // splatter FREE-AIM (slingshot). The pull is a RAY, not a landing point: its DIRECTION picks the // lane you fire down, its LENGTH is power — and nothing else. Pulling further does NOT reach // further, which is the whole fix: a shambler at the far baseline costs the same short pull as // one on top of you, so the gesture always fits on the screen. The first body standing in the // lane is the target; the cone widens with range so distant lanes stay pickable. const pullFrac = mag => Math.max(0, Math.min(1, (mag - TUNE.pullMin) / (TUNE.pullFull - TUNE.pullMin))); export const pullQuality = mag => TUNE.pullQMin + pullFrac(mag) * (1 - TUNE.pullQMin); // pullQuality inverted: hitServe is handed a quality, not a drag, and the band it leaves behind is // drawn at the same weight the live preview had. const qualityFrac = q => Math.max(0, Math.min(1, (q - TUNE.pullQMin) / (1 - TUNE.pullQMin))); // A pull that STOPS in the band at the top of the draw, rather than merely reaching it. See // TUNE.perfectBand for why the old "quality == 1" test praised almost every serve. export const pullPerfect = mag => mag >= TUNE.pullFull && mag <= TUNE.pullFull * TUNE.perfectBand; // The umpire has never once acknowledged the massacre, and he is not about to start now: a clean // shot gets the same line it would get at Wimbledon. Cycled rather than random — the same word twice // running is what made one word read as a stuck HUD element rather than as praise. const PRAISE = ['PERFECT', 'TEXTBOOK', 'IMMACULATE', 'OH, LOVELY', 'UNPLAYABLE', 'SHOT OF THE DAY']; let praiseN = 0; // ...and he does not say it every time. Same cooldown model as the asides (aside()): once you have // learned the band you hit it constantly, and a HUD that talks on every other serve is noise // whatever the word is. The bright click in the pull is the reliable feedback; this is the garnish. // Returns the word (or null, on cooldown) — the CALLER decides where and when it appears, because // the word must land at the IMPACT, not at the release (Oliver: "the reactions are playing after // shooting the ball, not when the ball hits"). A serve arms it on ball.praise and the contact/ // bounce pops it; a rally hit pops it at the racket, where contact and praise are the same moment. // ...and in a rally it lands BELOW THE PLAYER, just behind his feet, and stays put (Oliver, 2026-10-14: // "die Nachrichten wie shot of the day, textbook verdecken manchmal den Ball"). On the contact it rose // straight into the path of the ball he had just struck; a serve's word sat on the far bounce, which // is exactly where the return comes back through. The MOMENT is unchanged — only where it is drawn. // Gore keeps its words on the bodies they belong to. function praisePop(w, x, y, z) { if (rallyFeel() || G.mode === 'tennis') popText(P.x, 0, P.z + TUNE.praiseBehind, w, undefined, true); else popText(x, y, z, w); } function praiseWord() { if (G.praiseT > 0) return null; G.praiseT = TUNE.praiseEvery; return PRAISE[praiseN++ % PRAISE.length]; } // Court dust: a kicked-up puff at ground level, tinted per surface — chalk on clay, flecks on the // grass, grit on the slab. Squarely inside the "blood, fire, smoke" procedural exemption; the ball's // bounce and the sidestep both kick it, which is what pins fast feet and fast balls to the ground. const DUST_COL = { grass: '#8fa06a', clay: '#c9895a', hard: '#8494a4' }; function dustPuff(x, z, n = 5, sp = 1, col = null) { for (let k = 0; k < n; k++) parts.push({ x: x + (Math.random() * 2 - 1) * 0.15, y: 0.05, z: z + (Math.random() * 2 - 1) * 0.1, vx: (Math.random() * 2 - 1) * 1.2 * sp, vy: 0.5 + Math.random() * 0.9 * sp, vz: (Math.random() * 2 - 1) * 1.2 * sp, t: 0.25 + Math.random() * 0.2, spark: col || DUST_COL[court.id] || '#98948a' }); } // THE SKILL TRAIL (the leagues, phase 3): the player's OWN sprint dust wears the league colour — // the aura made of motion, never paint (the painted-layer veto). League 1 keeps the shipped court // dust: the tint itself is a progression reward. Read off the profile, not G.league, so the gore // mode shows your colour too. const skillDust = () => prof.lg > 1 ? (LEAGUES[prof.lg - 1] || LEAGUES[0]).color : null; export function flickTarget(ch, aim) { // exported: render draws the live reticle with it const q = pullQuality(aim.mag), f = pullFrac(aim.mag); if (aim.mag < TUNE.pullMin) return { q, f }; // barely drawn: no lane yet, so no target // NO AUTO-AIM IN THE DRILL. This used to lock the shot to the called body wherever you pointed, // and Oliver's verdict was the only one it could be: "no skills involved at all" — and with the // shot unmissable the fault could never fire either, so the mode had no penalty in it. You aim. // What DOES change is the tie-break: aimTie lets a nearer body inside the same cone steal a shot // aimed past it, which is right for a crowd you are mowing down and exactly wrong here, where // hitting the wrong body IS the failure. Zero slack — the body you are pointing at wins, always. const ax = aim.fx, az = aim.fy; // unit vector; input already inverted the pull. screen up = -z let zt = null, near = 1e9, bestErr = 1e9; for (const zb of zombies) { const dx = zb.x - ch.x, dz = zb.z - ch.z; const along = dx * ax + dz * az; // how far down the lane it stands if (along <= 0) continue; // behind the shot const cone = TUNE.zSnap + TUNE.aimCone * along; const err = Math.abs(dx * az - dz * ax) / cone; // 0 = dead on the ray, 1 = the cone's edge if (err > 1) continue; // outside the cone entirely // Pick the body you are actually POINTING AT, not merely the nearest one that happens to fall // inside the cone. "First in the lane wins" let a close shambler off to one side steal a shot // clearly aimed past it — the magnet Oliver felt. Range only breaks ties between equally // well-aimed targets (TUNE.aimTie of slack), so a deliberate far shot still beats a sloppy // near one while a genuine straight-ahead lane still eats the nearest body first. const tie = G.drill ? 0 : TUNE.aimTie; // the drill never guesses for you — see the note above if (err < bestErr - tie || (Math.abs(err - bestErr) <= tie && along < near)) { bestErr = Math.min(bestErr, err); near = along; zt = zb; } } const R = zt ? near : TUNE.aimReach; // empty lane: the ball just flies the full length of it const tx = Math.max(-6.5, Math.min(6.5, ch.x + ax * R)); const tz = Math.max(1, Math.min(24.5, ch.z + az * R)); return { tx, tz, zt, q, f }; } // splatter tap/lane aiming (no flick): the shot locks onto the best-matching zombie // anywhere on the court — including your own side of the net. function zombieTarget(aim) { if (!zombies.length) return null; const none = !aim || (!aim.ax && !aim.az); if (none) return null; // no direction, no lock. Free predictive auto-aim was the reason a // bare rhythm of taps could clear the court without any aiming at all. let best = null, bs = 1e9; for (const zb of zombies) { let s = Math.abs(zb.x - (P.x + aim.ax * 4)); // lane relative to where YOU stand if (aim.az < 0) s += zb.z * 0.35; // flick up: prefer the far ones if (aim.az > 0) s += (COURT.L - zb.z) * 0.35; // flick down: the close ones on your side if (s < bs) { bs = s; best = zb; } } return best; } // A FLICK'S LENGTH IS ITS STRENGTH. The rally aim read the gesture as three LANES — sideways past // 0.38 of its own length took the whole corner, a hair under it took dead centre — so one flick came // out "applied very strong" and the next "barely does anything", with nothing in between and no way // to tell which you had asked for. Length scales the sway continuously now, exactly as it already // did on the serve: a short swipe is a safe half-court ball, a committed one hunts the line. That is // also what hands the OUT back to the player — the scatter can only sail a shot that was already // aimed near the line, so going out becomes a risk you chose rather than one that was rolled for you. // Keys have no length; they keep the full lane. export const swayOf = aim => aim.mag === undefined ? aim.ax : aim.fx * Math.min(1, Math.max(0, (aim.mag - TUNE.flickMin) / (TUNE.flickFull - TUNE.flickMin))); // A return that left with a direction on it resets the flick lesson; a flat one is one more piece of // evidence that the player does not know the gesture is there. Exported for the suite — the touch // flick cannot be pressed headlessly, and this rule is the whole of the lesson's trigger. // It reads the same sway the shot does, so a swipe too short to bend the ball is honestly counted // as flat rather than resetting the lesson it just failed to use. export const flatCount = (streak, aim) => (aim && Math.abs(swayOf(aim)) > 0.15 ? 0 : streak + 1); // THE LEAGUES' pace tier, PER TERRITORY (2026-08-29, THE CLIMB): the tempo hangs on the band of // the CURRENT OPPONENT, not on the player's league — the low ladder plays at its own shipped // tempo and the world audibly pulls away as a climb crosses each band border. In the cup the two // readings coincide (the pool IS your band); the training court now practises a body at the // tempo you would really meet it at. Multiplies ball pace on BOTH rackets (doHit, both serve // paths) and the opponent's legs (rzChaseSpeed), so the world speeds up as one piece and the // reach tests keep believing the legs they measure. Netplay is a human and exempt. export const leaguePace = () => G.match && !G.net ? 1 + (leagueOfRung(G.match.opp).id - 1) * TUNE.leaguePace : 1; // THE DUEL LEARNS (the leagues, phase 4): 0 fresh, 1 at TUNE.duelRampGames — gore's own clock, // so game 0 is exactly the shipped duel and the league never rekeys the director. Exported for // the suite: the ramp is what makes a late tin worth something again. export const duelSkill = () => G.match ? 0 : Math.min(1, G.runGames / TUNE.duelRampGames); function doHit(ch, quality, aim) { // aim: {ax: -1|0|1, az: -1|0|1} from held keys / flick at contact (az -1 = deep, 1 = short) const toFar = ch.side === 'P'; if (ch.side === 'P') { G.passStreak = 0; // a landed swing ends the tap-to-hit lesson // ...and a landed swing WITH A DIRECTION ON IT ends the flick lesson. Counted here rather than // at the gesture because a flick that arrives too late to be read is a flick that taught the // player nothing: what matters is whether the ball left with an aim on it. Held keys count too. G.flatStreak = flatCount(G.flatStreak, aim); } // snap the ball to the racket at the contact frame so the hit visually connects ball.x = ch.x + ch.facing * 0.5; ball.z = ch.z + (toFar ? -0.3 : 0.3); const err = () => (Math.random() * 2 - 1) * TUNE.aimError * (1 - quality); let tx, tz, aimedLane = false, sway = 0, soft = null; let s = (TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed) * leaguePace(); const ft = G.mode !== 'tennis' && !G.interlude && ch.side === 'P' // the duel aims like tennis: lanes, not free-aim ? (aim && aim.fx !== undefined ? flickTarget(ch, aim) : { zt: zombieTarget(aim) }) : null; if (ft && ft.zt) { const zt = ft.zt; tx = zt.x + err() * 0.4; const T0 = Math.hypot(zt.x - ball.x, zt.z - ball.z) / s; tz = Math.min(23, zt.z + zt.v * T0) + err() * 0.3; // lead the shambler's walk } else if (ft && ft.tx !== undefined) { tx = ft.tx + err() * 0.4; tz = ft.tz + err() * 0.3; // no magnet: the ball goes where you flicked } else { // AN AIMED SHOT REACHES ONLY AS FAR AS YOUR TIMING EARNED IT. The singles line is 4.11 m out. // This used to aim a flick at a flat 3.3 m and then add err() on top — up to +-2.4 m at quality // 0 — so a flicked return with ordinary timing landed as wide as 5.7 m and the only clamp was // at 5. An UNAIMED return, targeting 0 + the same scatter, was always in. The flick, the one // thing in the mode that is supposed to be skill, was what lost the point: "8/10 times the ball // lands out". Reach now scales with the same quality the scatter shrinks with, so the two can // never add up past the line — worst case is 1.49 + 2.4 = 3.89 at quality 0, and a perfectly // timed flick is the full 3.3 with no scatter left on it at all. Placement is still earned; // it just stops being a coin toss you are punished for calling. const reach = ch.side === 'P' ? TUNE.flickReach + (1 - TUNE.flickReach) * quality : 1; sway = swayOf(aim); // -1..1, continuous on touch — see swayOf // AN AIMED SHOT KEEPS ITS DIRECTION. The scatter used to be symmetric, and at rushed quality // it was BIGGER than the quality-shrunk aim — a mistimed left flick landed anywhere in a 4 m // blob around "slightly left", i.e. usually centre and sometimes right. It read as the ball // landing wherever the opponent stood (2026-08-20, "trying to move the approaching ball with // a flick to the left is almost impossible"). An OUTWARD roll is untouched — the committed // swipe's chance of sailing wide is a chosen risk with a tuned shape (flickReach) and this // must not soften it — but an inward roll now keeps only TUNE.aimTrust of itself, so bad // timing costs WIDTH, never the direction that was asked for. Player only: the opponent's // symmetric scatter is its width mechanic (matchWide clamps it, not this). let e = err(); if (ch.side === 'P' && sway && e * Math.sign(sway) < 0) e *= TUNE.aimTrust; tx = sway * (toFar ? TUNE.flickWidth : 2.7) * reach + e; // ...AND AN AIMED BALL NEVER LANDS ON THE WRONG SIDE OF YOU. The lane is ABSOLUTE (a fully // relative lane was tried 2026-08-20 and feel-vetoed the same day — "way too difficult now, // I am dying constantly of out"), and an absolute lane has a blind spot: a mistimed left // flick's target sits only slightly left of CENTRE, which is RIGHT of a player standing in // the left service box — so the ball visibly flew right out of a left flick ("the ball // sometimes actually flies a little to the right, although I flicked left"). The target is // clamped to the player's own x on the flicked side now (bounded at flickWidth, so a body // parked in the alley cannot conjure an out): from the left box a left flick goes at least // down your own line, from centre it can no longer cross centre, and a perfect corner flick // is untouched. No new out risk — the clamp only ever pulls the target TOWARD the line the // player is standing on, never past flickWidth, and the outward scatter tail is unchanged. if (ch.side === 'P' && sway) { const bound = Math.max(-TUNE.flickWidth, Math.min(TUNE.flickWidth, ch.x)); tx = sway < 0 ? Math.min(tx, bound) : Math.max(tx, bound); } // a PUT-AWAY goes for the line, not the rally lane (the match's width dial below still bounds it, // so the bottom of the ladder cannot paint lines even when it punishes) if (aim && (aim.kill || aim.pass)) tx = (aim.ax || (P.x > 0 ? -1 : 1)) * TUNE.punishX + err() * 0.3; // ...and the player's SMASH goes for the open court the same way: his side if he flicked one, else // away from the body across the net. Down the middle, deep, it came straight back to the baseliner if (ch.side === 'P' && aim && aim.smash) { const foe = G.match ? zombies.find(z => z.variant === 'r') : O; tx = (sway ? Math.sign(sway) : foe && foe.x > 0 ? -1 : 1) * TUNE.punishX + err() * 0.3; } // 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). // THE SOFT BALL (step 3, Oliver's pick 2026-10-13): in a rally mode the DOWN-flick is soft, always, // and its LENGTH is how far — a short one is a drop just over the net, a long one a lob into the // back corner (the opponent's moonball uses the space between). One gesture, one meaning, whoever is // where: this is NOT the context-armed lob that was cut on 2026-08-22. Mistiming pulls it toward // softSitter — a drop that sits up, a lob that falls short: the easy ball either way. // `aim.soft` lets a caller with no swipe (the opponent, step 2) ask for one; keys take the lob. // TWO shots, not a continuum (Oliver on v363: "nach unten erzeugt gefühlt immer einen Lob ... mir // ist ein Stoppball nicht gelungen"). The swing fires at flickMin and the length is read at // contact, by when a thumb has usually travelled on — and on a continuum even 50 px already // floated the ball to mid-court. Below dropMax it is the drop, at or past it the lob. soft = rallyFeel() && aim.az > 0 ? (aim.soft ?? (aim.mag === undefined || aim.mag >= TUNE.dropMax ? 1 : 0)) : null; if (soft !== null) { const want = COURT.netZ - TUNE.dropNet + (TUNE.lobDeep - COURT.netZ + TUNE.dropNet) * soft; // from his baseline const d = want + (TUNE.softSitter - want) * (1 - quality) * TUNE.softErr; tz = toFar ? d : COURT.L - d; } else { 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 // still lands the ball on the same tz, so extra speed makes the shot flatter and earlier rather // than longer. A smash you can hit out on a whim would just be the bug above wearing a hat. if (ch.side === 'P' && aim.az < 0) s *= 1 + TUNE.smashBoost * quality; // ...and an OVERHEAD taken out of the air is the real smash (trySwing marks it): hit it hard if (ch.side === 'P' && aim.smash) s *= 1 + TUNE.smashPace * quality; aimedLane = true; } tx = Math.max(-5, Math.min(5, tx)); // THE READ (see config's dial block): the opponent studies where your rally shots GO. Three // lanes, bucketed off the aimed target — run readHabit of the same lane together and the habit // is learned: the NEXT same-lane ball is `expected` (anticipated in racketAI), a lane break is // `surprised` (wrong-footed, for this one flight). Solo match rally shots only: netplay's // opponent is a human who does his own reading, and serves keep their own game. if (G.match && !G.net && ch.side === 'P' && aimedLane) { const d = G.match, lane = tx < -1.1 ? -1 : tx > 1.1 ? 1 : 0; d.surprised = (d.laneRun || 0) >= TUNE.readHabit && d.lastLane !== undefined && lane !== d.lastLane; d.expected = (d.laneRun || 0) >= TUNE.readHabit && lane === d.lastLane; d.laneRun = lane === d.lastLane ? (d.laneRun || 0) + 1 : 1; d.lastLane = lane; // the mechanic announces itself the moment it fires — on the rungs young enough to barely // lean, the flash is the whole lesson for later if (d.surprised && (d.opp - 1) / (LADDER - 1) > 0.2) G.flash = { text: 'WRONG-FOOTED.', t: 1.0 }; } if (G.match && ch.side !== 'P') { // ...AND ITS PACE IS THE FOURTH. Width alone did not fix "unreachable" because the returns left // at ~27 m/s (baseShotSpeed + 0.55 quality) — crossing to your baseline in ~0.6 s, which is less // time than auto-run needs from the far corner. Opponent one gives you the flight time; the // rungs take it back. // ...and the RUNG's own pace trait rides on top, inside the cap: full pace is still the ceiling, // an opponent just gets there sooner or later than the flat curve would (see matchFoe in config). s *= Math.min(1, (TUNE.matchOppPace + matchStep() * TUNE.matchOppPacePer) * (ch.foe?.pace || 1)); } if (G.match) { // THE OPPONENT'S WIDTH IS THE THIRD DIAL. Its returns scatter off aimError x (1 - 0.55 // quality), which paints lines an auto-run player cannot reach — "still a LOT of unreachable // balls". Opponent one keeps its returns inside matchWideBase of the centre line; each rung // earns matchWidePer more width, so the line-painting is the LATE ladder, not the opening one. if (ch.side !== 'P') { const wide = (TUNE.matchWideBase + matchStep() * TUNE.matchWidePer) * (ch.foe?.wide || 1); tx = Math.max(-wide, Math.min(wide, tx)); } } // THE PLACEMENT, MADE VISIBLE: your aimed rally hit stamps its landing point, and render draws a // fading ring there — flick, see the ring bite the corner, learn the cause. Only when a flick or // held direction actually aimed it: an unaimed hit marking its scatter would teach noise. // It carries `q` now, so the ring also says HOW MUCH OF THE FLICK LANDED — tight and hot when your // timing earned the full reach, wide and dim when it barely applied. Without that the new // quality-scaled reach above is invisible: the shot simply goes less wide and nothing says why. if (aimedLane && ch.side === 'P' && aim && (sway || aim.az)) { G.placeMark = { x: tx, z: tz, t: 0.8, q: quality }; } // topspin: quality shots dive under extra effective gravity, which is what lets real // pace arc over the net and still land in — the core tennis trade-off. The soft ball carries none. const g = soft !== null ? TUNE.gravity : TUNE.gravity * (1 + quality * TUNE.spinFactor); // ...and its PACE IS DERIVED FROM ITS HEIGHT (the cut lob's ballistics, 4f4534e): to peak at // `apex` it must leave at vy = sqrt(2g(apex - y)), which fixes the hang time, and the ground still // to cover fixes the speed — the same arc from the baseline and from the net. After the // opponent's pace dials on purpose: a floater is a floater from any racket. if (soft !== null) { const apex = TUNE.dropApex + (TUNE.lobApex - TUNE.dropApex) * soft; const vy0 = Math.sqrt(2 * TUNE.gravity * Math.max(0.5, apex - ball.y)); const hang = (vy0 + Math.sqrt(vy0 * vy0 + 2 * TUNE.gravity * Math.max(0, ball.y - 0.08))) / TUNE.gravity; s = Math.hypot(tx - ball.x, tz - ball.z) / hang; } let v = solveShot(ball.x, ball.y, ball.z, tx, tz, s, g); // low contact → trade pace for loft until the net is cleared; good timing gets a // comfortable margin, sloppy timing keeps some tape risk. Same crossesNet guard as serveArc's, // for the same reason: a splatter volley at a body on your own side has no tape to clear, and // without this it was lobbed at a fraction of its pace. (In tennis the ball always crosses.) const margin = 0.06 + 0.22 * quality; const crossesNet = (ball.z - COURT.netZ) * (tz - COURT.netZ) < 0; for (let i = 0; crossesNet && i < NET_LOFT_TRIES; i++) { const tN = (COURT.netZ - ball.z) / v.vz; const yN = ball.y + v.vy * tN - 0.5 * g * tN * tN; if (tN <= 0 || yN >= COURT.netH + margin) break; s *= 0.9; v = solveShot(ball.x, ball.y, ball.z, tx, tz, s, g); } ball.g = g; ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz; // a drop carries backspin and DIES after its bounce; it fades out across the soft range to a lob ball.spin = soft === null ? (aim && aim.spin) || 1 : 1 - (1 - TUNE.dropSpin) * Math.max(0, 1 - soft * 2); ball.soft = soft !== null; // ...and it is DISGUISED: racketAI reads it late (TUNE.softRead) // ...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.retreat = null; // his own way back is a DOUBLE TAP while this ball flies (moveCall), never the shot ball.smash = !!(aim && aim.smash); 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 ball.heels = aim && aim.kill ? TUNE.punishRead : 0; // a put-away: he is on his heels (see the auto-run) // ...and a player AT THE NET (or rushing it) split-steps before he reads the pass, exactly as the CPU's // net man does (netReact): with no read at all his 2.5 m volley reach made him unpassable if (ch.side !== 'P' && rallyFeel() && (Math.abs(P.z - COURT.netZ) < TUNE.netFrom || P.rushing)) ball.heels = Math.max(ball.heels, TUNE.netReact); // THE SWING LOG's other half: what came at you. A LOST "passed you" line is unreadable without it — // was that ball reachable at all? And "never swung" is judged per incoming ball, not once a point. if (ch.side !== 'P' && swingLog()) { G.swung = false; G.rallyNear = null; const land = predictLanding(), mark = !land ? '' : (() => { const dx = land.x - P.x, inside = r2(COURT.L - land.z); const out = Math.abs(land.x) > COURT.W / 2 || land.z > COURT.L || land.z < COURT.netZ; return ` · lands ${r2(Math.abs(dx))}m ${dx < 0 ? 'left' : 'right'} of you, ${inside}m inside your line${out ? ' (OUT)' : ''}`; })(); const kind = aim && aim.kill ? 'PUT-AWAY of your weak ball' : soft === null ? (aim && aim.az < 0 ? 'deep' : 'drive') + (ball.spin > 1 ? ' topspin' : ball.spin < 1 ? ' slice' : '') : soft === 0 ? 'drop' : soft >= 1 ? 'lob' : 'moonball'; logSwing({ result: 'THEM', reason: `${kind} q=${r2(quality)} · ${Math.round(Math.hypot(ball.vx, ball.vz))}m/s${mark}` + (ball.approach === ch.side ? ' · coming in' : ''), px: r2(P.x), pz: r2(P.z) }); // where HE stood when it left } 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 ball.bomb = false; ball.flame = false; ball.wid = 'ball'; // only a consuming serve arms these; any other hit clears them ball.chain = 0; // ...and the slice dies with the shot that earned it recordEvent('shot', { kind: 'return', side: ch.side, quality, aim: aim ? { ...aim } : null, target: [tx, tz] }); ball.praise = null; // a re-hit is a new shot: a stale armed word must not pop on ITS impact // (the landing ring that used to be stamped here confused testers — the umpire calls the ball, // the court stays clean) if (ch.side === 'P') G.aiReactT = 0; // CPU hasn't read the shot yet // ...and the CRACK carries the pace (same report as the ball streak): one recorded sample, pitched // by how fast the ball actually left. It is the striker's cue at the one moment he is looking at // his own racket rather than at the far court. sfx(ch.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true, rate: strikeRate() }); // recorded racket strike, per side if (ch.side === 'P') strikeKick(); // CONTACT IS THE TICK. Only YOUR racket: the far side's strike is something you watch, not // something your hand did, and buzzing for both halves of every rally is the numbing failure mode. if (ch.side === 'P') haptic(quality > 0.999 ? 'medium' : 'light'); // a perfect hit is felt as one if (quality > 0.999 && ch.side === 'P') { // the rally hit: a real 45 ms window (perfectMs) G.hitstop = 0.05; pock(0.5, 1600); const w = praiseWord(); // a rally hit IS the impact — the word pops right on the contact if (w) praisePop(w, ball.x, ball.y, ball.z); } ch.pose = 'swing'; ch.poseT = 0; if (G.net && ch.side === 'P') netSendShot(); // netplay: the shot IS the sync unit } // ----------------------------------------------------------------- serve ---- export function tossBall(sv) { // exported for net.js: a remote toss replays as a real toss over O ball.active = true; ball.bounces = 0; ball.collected = false; // The ball rises over the tossing HAND, not the head — TOSS.dx/oDx are measured off the toss // frames' raised fingertips (see config). `* facing` because drawChar mirrors the whole sprite // when the server faces left, exactly like the idle bounce's offset. ball.x = sv.x + sv.facing * (sv.side === 'P' ? (tour.tossDx ?? TOSS.dx) : TOSS.oDx); ball.z = sv.z; ball.y = TOSS.y0; ball.vx = 0; ball.vz = 0; ball.vy = TOSS.vy; ball.g = TUNE.gravity; // THE TOSS IS THE SERVER'S BALL. lastHit was left stale from the previous rally, and if the // OPPONENT had touched it last, the freshly tossed ball read as *their* ball in flight — which // armed every rule that judges an opponent ball, most fatally the passing-shot test: the match // serves from BEHIND the baseline (z 24.3 > the test's 24.07), so the moment you tossed after any // rally they had touched, "its passing shot ends the duel" fired, the point went to them and drew // blood. That was Oliver's death loop: every tap = a toss = an instant lost point, faster the // more he panicked. One stamp here fixes every caller — the duel and the gore mode carried the // same latent bug for anyone tossing from deep behind the line. ball.lastHit = sv.side; G.phase = 'toss'; // netplay: the toss travels too, so the receiver watches the serve COMING instead of a ball // appearing from nothing — return timing is half the sport and it needs the wind-up if (G.net && sv.side === 'P') netSendToss(); } export function serveApexY() { return TOSS.y0 + TOSS.vy * TOSS.vy / (2 * TUNE.gravity); } // ~2.95 // how many 10%-pace cuts a shot may take to get over the net. 8 wasn't enough for a target // sitting right behind the tape — that needs a near-vertical drop, so ~10 cuts (pace × 0.35). const NET_LOFT_TRIES = 12; // ---- ONE SOLVER for the slingshot serve. The reticle draws the arc the ball will actually fly, // so the targeting and the ballistics below are shared with hitServe rather than reimplemented. // The moment a preview and a shot come from two copies of this maths they drift, and then the // preview is worse than nothing — it lies with confidence. // dragServe snaps the ball to a FIXED contact before striking, which is the whole reason the arc // is previewable: the launch point is known while you are still pulling. Both live here. export const dragContact = () => ({ x: P.x + 0.12, y: 2.2, z: P.z }); // Where a splatter serve is aimed. `ex` is the aim error: hitServe passes the real random one, // the preview passes 0, so the arc you see is the shot you MEANT rather than the shot you got. // Returns null when this is not a free-aim/lane serve at all (the tennis serve keeps its box). function splatServeTarget(sv, aim, s, x0, z0, ex = 0) { if (G.mode === 'tennis' || G.interlude) return null; // the duel serves like tennis const ft = aim && aim.fx !== undefined ? flickTarget(sv, aim) : { zt: zombieTarget(aim) }; if (ft.zt) { const T0 = Math.hypot(ft.zt.x - x0, ft.zt.z - z0) / s; // ty: aim at the CHEST of whatever this is, so the ball arrives on the body it is going to // wound. Scaled off the same height render draws it at, so a brute is struck on the brute. return { tx: ft.zt.x + ex, tz: Math.min(23, ft.zt.z + ft.zt.v * T0), // lead the walk ty: zombieH(ft.zt) * Z_CHEST, ft }; } if (ft.tx !== undefined) return { tx: ft.tx, tz: ft.tz, ty: 0.08, ft }; // empty lane: onto the grass return null; } // 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, hard = 0) { // hard: the aimed strike's 0..1 let s = (TUNE.serveSpeed + quality * TUNE.serveQualitySpeed + hard * TUNE.serveHard) * leaguePace(); // 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 // in the direction of travel, the ball simply lands first), the height test there is far below // the tape, and the loop cut pace all twelve times. Measured: every same-side shot left the // racket at 28% of nominal, as a slow loop, which is exactly the shot you need to be fast — // those are the ones about to eat you. const crossesNet = (z0 - COURT.netZ) * (tz - COURT.netZ) < 0; for (let i = 0; crossesNet && i < NET_LOFT_TRIES; i++) { const tN = (COURT.netZ - z0) / v.vz; const yN = y0 + v.vy * tN - 0.5 * g * tN * tN; // The sim only samples the net plane once a frame, so a steeply DIVING ball can test below // the tape even when the closed form clears it. Charge margin for exactly that sampling // error (descent rate × the worst frame step) rather than a flat quality cushion — that // cushion is paid for in pace, and it was taxing precisely the shots meant to hurt. const margin = 0.06 + Math.abs(v.vy - g * tN) * 0.04; // 0.04 s = main.js's dt clamp if (tN <= 0 || yN >= COURT.netH + margin) break; s *= 0.9; v = solveShot(x0, y0, z0, tx, tz, s, g, ty); } return { v, g }; } // What render draws while you pull: the whole shot, error-free, from the same two functions the // release will use. null when there is nothing to show yet. export function previewServe(aim) { if (!aim || aim.mag < TUNE.pullMin) return null; const q = pullQuality(aim.mag); const c = dragContact(); const s = TUNE.serveSpeed + q * TUNE.serveQualitySpeed; const st = splatServeTarget(P, aim, s, c.x, c.z); if (!st) return null; const { v, g } = serveArc(c.x, c.y, c.z, st.tx, st.tz, q, st.ty); // ...and WHETHER THIS ONE SLICES. The perfect window was invisible until now: quality saturates // at pullFull, so an overpull and a perfect pull drew an identical band and you were being asked // to hit a target the game never showed you. Now the band goes chalk-white inside it. Plain balls // only, because only a plain ball slices (see hitServe). return shotBand(c.x, c.y, c.z, v, g, st.tx, st.tz, st.ty, !!st.ft.zt, q, pullPerfect(aim.mag) && G.weapon === 0 && !G.temp); } // The drawable form of a shot: everything render needs and nothing that would pin a zombie object // alive (`zt` is a flag, not the body — the trail below outlives whatever it was aimed at). `ty` is // the arrival height, so the mark render paints sits exactly where the ball is going. function shotBand(x0, y0, z0, v, g, tx, tz, ty, zt, q, pf = false) { const T = Math.hypot(tx - x0, tz - z0) / (Math.hypot(v.vx, v.vz) || 1); // flight time to target return { x0, y0, z0, v, g, tx, tz, ty, zt, q, pf, f: qualityFrac(q), T, t: 0 }; } // ---- THE SLICE CAROM. A sliced ball does not steer. At the instant it kills something it is // STRUCK AGAIN — one new velocity out of the same solver a serve uses, aimed at the next body's // chest — and from the next frame it is an ordinary ball under ordinary gravity. // This replaces a hook that turned toward its target a little more every frame, and the reason is // not that the hook was buggy twice (it was) but that it was the wrong thing to build: EVERY other // ball in this game flies a parabola you can read, so the one that thinks reads as a glitch even on // the shots where it works. Oliver, on watching one circle a wounded shambler: "an orbiting tennis // ball midair, nobody knows what is going on." A carom cannot orbit, cannot hover and cannot // outlive its shot, because after this function returns there is no slice left running anywhere — // which is also why the wound case needed no fix of its own. A ball spent on a body it failed to // kill simply drops, like every other spent ball. // The hop draws its own band, white, so the chain leaves a chalk arc from the corpse to the next // one — the same object the aim preview and the serve already use, given a target of its own. export function sliceCarom(tgt, speed) { const g = ball.g || TUNE.gravity, ty = zombieH(tgt) * Z_CHEST; const v = solveShot(ball.x, ball.y, ball.z, tgt.x, tgt.z, speed, g, ty); ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz; G.trail = shotBand(ball.x, ball.y, ball.z, v, g, tgt.x, tgt.z, ty, true, 1, true); } // `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. // 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 // 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) * leaguePace(); // 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); let boxX, tzs; if (st) { boxX = st.tx; tzs = st.tz; } else { // target: diagonal service box. Serving from deuce side → target box is across. const scatter = (0.8 + hard * TUNE.serveHardErr) * (1 - quality); // THE AIM BASE NEVER LEAVES THE BOX. A full sway used to carry the target ACROSS the centre // line — an aimed serve was a fault by construction, not by risk. The sway now runs from the // box centre to 0.18 m INSIDE either line, and only the timing scatter (added after) can // carry the ball out — which is the tennis being asked for: the line is where you aim, the // miss is what you risked. (Classic enforces the box; see fault()'s caller.) const side = (deuceSide() ? -1 : 1) * (toFar ? 1 : -1); const half = COURT.W / 2; const aim = side * 2.0 + sx * TUNE.serveAimX; const lo = side < 0 ? -half + 0.18 : 0.18, hi = side < 0 ? -0.18 : half - 0.18; boxX = Math.max(lo, Math.min(hi, aim)) + (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, 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 // ACTUAL launch, error included, so the ball tracks the band it is consuming exactly. if (st) G.trail = shotBand(ball.x, ball.y, ball.z, v, g, boxX, tzs, st.ty, !!st.ft.zt, quality, 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.rx = null; ball.heels = 0; ball.retreat = null; ball.smash = false; ball.spin = 1; ball.soft = false; // a serve-and-volleyer follows its serve in (STYLES.sv) — the serve is its approach shot ball.approach = sv.side === 'O' && styleOf(zombies.find(z => z.variant === 'r'))?.sv ? 'O' : 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 // the SELECTED tin arms the serve; slot 0 (PLAIN) throws a normal ball and banks your // specials for later. Placement still follows the flick — only WHICH ball flies is the // rack's call now. GUN (slot 4) never reaches here; it starts a burst before the serve. // ...and the DUEL is plain balls, full stop. startDuel forces slot 0, but slot 0 routes through // G.temp — so with a temp weapon riding it every duel serve armed and spent the temp (a laser // fired instead of the ball and aborted the point). Tennis rules means 'ball', temp or no temp. const wsel = G.mode === 'splatter' && !G.interlude ? G.weapon : 0; const wid = G.interlude || (wsel === 0 && !G.temp) ? 'ball' : slotId(wsel), armed = slotStock(wsel) > 0; ball.wid = armed ? wid : 'ball'; // an unstocked tin throws a plain ball — file the kills that way // THE SLICE. A perfect serve with a PLAIN ball is cut: it hooks body to body (see splatter). // Plain only — a bomb, a flame ball and a laser already have an identity, and stacking a carom // on top of one would read as the tin misbehaving rather than as something you did. ball.chain = perfect && ball.wid === 'ball' ? TUNE.sliceHops : 0; // THE LASER fires instead of the ball, down the lane this serve was aimed at. Everything else // about the gesture is untouched — the pull, the aim, the release — which is the whole point of it. if (wid === 'laser' && armed) { spendSlot(wsel); fireBeam(ball.vx, ball.vz); ball.active = false; G.phase = 'over'; G.overT = 0.5; sv.pose = 'serve'; sv.poseT = 0; return; } ball.bomb = wid === 'bomb' && armed; if (ball.bomb) spendSlot(wsel); ball.flame = wid === 'fire' && armed; if (ball.flame) spendSlot(wsel); if (wid === 'triple' && armed) { // one serve, three balls: clones fanned ±6° spendSlot(wsel); for (const deg of [-6, 6]) { const r = deg * Math.PI / 180, c = Math.cos(r), sn = Math.sin(r); // g: ball.g — a serve carries up to 2.3x gravity in topspin; clones integrated at flat // TUNE.gravity floated long while the centre ball dived, which is the "wings lob off at // the wrong height" report. They are the SAME shot, fanned. extras.push({ x: ball.x, y: ball.y, z: ball.z, vy: ball.vy, pow: quality, t: 2.5, g: ball.g, wid: 'triple', vx: ball.vx * c - ball.vz * sn, vz: ball.vx * sn + ball.vz * c }); } } // (spendSlot already hands you back plain balls when the last one goes) G.phase = 'rally'; sfx(sv.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true, rate: strikeRate() }); // recorded serve strike, per side recordEvent('shot', { kind: 'serve', side: sv.side, quality, perfect, aim: aim ? { ...aim } : null }); if (sv.side === 'P') strikeKick(); if (sv.side === 'P') haptic(perfect ? 'medium' : 'light'); // the strike, same tick as a rally hit sv.pose = 'serve'; sv.poseT = 0; if (perfect && sv.side === 'P') { G.hitstop = 0.05; // the word is ARMED here and pops at the IMPACT — first body the ball touches, or its first // bounce. Praising at release meant the HUD spoke while the ball was still mid-air. ball.praise = praiseWord(); } if (G.net && sv.side === 'P') netSendShot(); // netplay: a serve is a shot event like any other } // ---------------------------------------------------------------- scoring ---- export function scoreKey() { const N = ['love', 'fifteen', 'thirty', 'forty']; const s = score.server === 'P' ? [score.pts[0], score.pts[1]] : [score.pts[1], score.pts[0]]; if (s[0] >= 3 && s[1] >= 3) { if (s[0] === s[1]) return 'deuce'; return (s[0] > s[1]) === (score.server === 'P') ? 'advantage_player' : 'advantage_receiver'; } if (s[0] === s[1]) return N[s[0]] + '_all'; return N[s[0]] + '_' + N[s[1]]; } // ---- the modal: the only thing in the game that stops the world. Play resumes on a tap, not // on a timer, so a popup can be READ. It only ever touches G — never the zombie array it may be // called from mid-iteration. // A second popup opened in the same beat QUEUES rather than being dropped. It used to be dropped // ("if (G.modal) return"), which is why the trainer and the weapon had to be crammed into one card // after a won duel — the second one simply vanished. They are two things, so they are two popups. export function showModal(m) { m.in = 0; // 0..1 entrance clock, ticked in update() behind the freeze if (G.modal) { G.modalQ.push(m); return; } G.modal = m; whoosh(); } export function dismissModal() { const m = G.modal; if (!m) return false; // Still flying in? Eat the gesture but DON'T dismiss. A modal you haven't read yet must not be // skippable by the same panic tap that was mid-air when it appeared — and returning false here // would let that tap fall through into a serve, which is the swallowed-gesture bug all over again. if (m.in < 1) return true; G.modal = G.modalQ.shift() || null; if (G.modal) { G.modal.in = 0; whoosh(); } // the next one flies in on its own entrance if (m.onDismiss) m.onDismiss(); return true; // the caller must swallow the gesture: dismissing must never also serve } // The umpire's asides. He has never once acknowledged that the match turned into a massacre, and // that is the joke — so he keeps officiating it. But each line is tied to a SITUATION that actually // justifies it, not drawn from a bag on a timer: Oliver's verdict on the first version was "too much // and not situation aware.. they seem random". `aside()` is the single entry point and it enforces // one shared cooldown (TUNE.asideEvery), so several triggers firing at once still yields one remark. export function aside(name, delay = 0.2) { // DANIEL IS BENCHED (2026-08-13). Oliver: "daniel is very repetetive. please let him just // announce the normal tennis game events for now.. this is a task for another time." So the // umpire keeps every OFFICIATING call (score, game/set, fault, out, let, time, play) and loses // ALL personality asides — this early return is the whole cut, since every ump_* remark routes // through here. The situation triggers and the cooldown model below survive intact for the // personality pass that will re-open him; do not delete them. return; // THE MATCH IS THE EXCEPTION to the interlude bar, exactly as sendBoy is: the match runs on a // PERMANENT interlude, so this line silently muted the umpire for an entire mode — the one mode // that is literally a tennis match he is sitting over. The shared asideEvery cooldown still holds, // which is the rule that keeps him situational rather than chatty ("too much and not situation // aware.. they seem random"). Cards still block him: a name plate is already the mode talking. if (G.mode !== 'splatter' || G.modal || G.changeover || G.card) return; if (G.interlude && !G.match) return; if (G.asideT > 0) return; G.asideT = G.match ? TUNE.matchAsideEvery : TUNE.asideEvery; voice(name, delay, 0.95); } export function cheer(seconds, vol) { sfx('tennis-applaus', Math.min(1.5, vol * 2.2)); // recorded crowd; vol scales with the size of the moment (gain > 1 amplifies) // HOW BIG THIS ONE WAS. vol is already the size of the moment everywhere it is called from (a // won rally 0.25, a game 0.6, a match or an EPIC 1.0), so the stand reads it rather than growing // a second scale of its own: past 0.6 the wave rolls, and the photographers in the stand fire a // burst proportional to it. Held at the LOUDEST while an applause is live — a small cheer landing // inside a big one must not quietly demote it. crowd.pop = Math.max(crowd.cheer > 0 ? crowd.pop : 0, Math.min(1, vol)); crowd.burst += Math.round(crowd.pop * 7); // floored at the CLIP LENGTH: the sample always plays out in full, so the stand must stay on its // feet until it stops. `seconds` still lets a big moment run longer than the audio. crowd.cheer = Math.max(crowd.cheer, seconds, APPLAUSE_T); } // Games between weapon barrels. Exported for the suite, because the shape of this curve IS the // gore mode's economy and it is one line of arithmetic that once quietly became "one per game". // The floor is the point: however long a run gets, a barrel stays something you waited for. export const barrelGap = () => Math.max(TUNE.barrelFloor, TUNE.barrelEvery - Math.floor(G.runGames / TUNE.barrelRamp)); // ...and how many ball machines the late court is allowed to hold at once. Same idea from the other // end: the barrels stop growing, this one starts. export const machCap = () => Math.min(TUNE.machMaxCap, TUNE.machMax + Math.floor(G.runGames / TUNE.machMaxPer)); export function advancePoint(side) { // shared scoring math; returns whether a game was won const i = side === 'P' ? 0 : 1, j = 1 - i; score.pts[i]++; // THE ONE XP HOOK for points and games: every mode's point converges here (pointTo, matchPoint, // splatPoint), so the leagues' clock cannot miss a mode or double-count one. award() itself // mutes the no-stakes modes (training, drill, bench, test). if (side === 'P') award('point'); if (score.pts[i] >= 4 && score.pts[i] - score.pts[j] >= 2) { score.games[i]++; score.pts = [0, 0]; if (side === 'P') award('game'); return true; } return false; } // A DECIDED FLIGHT. Everywhere but netplay the ball stops dead where it always has; over the wire // it keeps its physics and rolls to a natural halt, because the striker's phone is a whole court // and a latency away from the call and freezing its ball mid-roll read as the game loop stopping // (Oliver, 2026-08-15). `ball.scored` is what switches off every scoring consequence downstream. const endFlight = () => { if (G.net) ball.scored = true; else ball.active = false; }; export function pointTo(side, reason) { // exported for net.js: a remote verdict lands here // netplay: this phone judged the live flight — the verdict goes over the wire before it is // applied, so both scoreboards move on the same call. netJudging() is false while a REMOTE // verdict is being applied, which is what stops the two phones echoing a point back and forth. if (netJudging()) netSendPoint(side, reason); if (G.mode === 'tennis' && side === 'O' && ball.lastHit === 'O' && G.rallyNear && !G.swung) G.passStreak++; // their ball died on your side untouched: two in a row and the ghost hand shows the tap (render.drawHitHint) logPoint(side, reason); const gameOver = advancePoint(side); G.phase = 'over'; G.overT = gameOver ? 3.2 : 2.2; sendBoy(); if (side === 'O') aww(0.8); // you dropped the point — the crowd sighs if (reason) voice(reason); if (gameOver) { // ...unless this is CLASSIC TENNIS, where nothing turns and the first game you win is just the // first game you win. That check is the whole of the mode's isolation from the gore path. if (G.mode === 'tennis' && side === 'P' && !G.everTransitioned && !G.classic) return startTransition(); voice(side === 'P' ? 'game_player' : 'game_opponent', 0.7); cheer(2.8, 0.6); score.server = other(score.server); if (G.classic) { G.runGames++; if (side === 'P') G.classicWon++; // the number its board ranks if (classicGame(side)) return; // ...then a set, then the match } } else { voice(scoreKey(), 0.7); if (side === 'P') cheer(1.2, 0.3); } G.serveNum = 1; } // CLASSIC TENNIS: a game has just been banked by advancePoint — is it also a set, and is the set // also the match? Returns true when it ended the match, so pointTo stops handing the point flow on. // Six games AND a two-game lead, the real rule; no tiebreak, so 6-6 simply plays on to 8-6. That is // the one simplification here and it is deliberate — a tiebreak is a different scoring mode bolted // onto a mode that is not yet proven, and 7-5 sets are not what anybody is complaining about. // ponytail: add the tiebreak when a real match actually stalls at 6-6. // Exported for the suite: the whole of a mode's scoring rule is worth pinning down, and the only // other way in is winning twelve games through real rallies. export function classicGame(side) { const i = side === 'P' ? 0 : 1, j = 1 - i; if (score.games[i] < 6 || score.games[i] - score.games[j] < 2) return false; G.sets[i]++; score.games = [0, 0]; const mine = i === 0; if (G.sets[i] >= TUNE.classicSets) { endClassic(mine); return true; } G.card = { title: 'SET ' + (G.sets[0] + G.sets[1]), sub1: mine ? 'THE SET IS YOURS' : 'SET TO THEM', color: mine ? UI.pick : UI.hot, t: TUNE.setCardT }; G.overT = Math.max(G.overT, TUNE.setCardT); return false; } // The match is over and NOBODY DIED — the one end-of-run state that is not a corpse. It reuses the // death screen's whole shape (a held summary, then a gated tap that restarts) because that is the // only thing about the two endings that is the same; the words, the colour and the still-standing // player are all its own. update() freezes on G.done, exactly as it does on G.mode === 'eaten'. export function endClassic(won) { if (G.done) return; G.done = { won, t: 0 }; ball.active = false; G.flash = null; G.modal = null; G.card = null; if (won) { cheer(4, 1); applause(1, 0.2); voice('unbelievable', 0.4); } else { aww(1); voice('game_opponent', 0.3); } // g = GAMES PLAYED (what the stats screen calls LONGEST MATCH), s = GAMES YOU WON — this mode's // two headline numbers in the record's two slots. `s` was SETS until the classic board arrived, // and nothing ever displayed it: the sets line on the death screen is gore's, and read() has // never looked at `s` at all. Games won is the number worth ranking, because every classic win is // two sets and a board of nothing but 2s is a wall of ties. // `dead` carries the RESULT: a match you lost is the run ending badly, a match you won is not, // so the stats screen's DEATHS column reads as matches lost and (runs − deaths) as matches won, // with no extra field to keep in step. const b = endRun(G.runGames, G.classicWon, G.splatT, !won, MODE_CLASSIC); G.best = b; G.newBest = b.newBest; } // splatter-mode scoring: points land mid-flight without interrupting play. // quiet (during a horde) rate-limits the umpire so he doesn't call every rapid-fire point. // The scoreboard IS the progression: games drive spawn pressure, 6 games = a set = a horde, // odd games end in a changeover breather. // Which bodies gate the hand-clear after a won game. The racket zombie keeps its residence, and the // ball machine is furniture — a standing threat you deal with WHEN YOU CHOOSE. Making either block // progression would turn "decide what to prioritise" into "clear the chore list". // A barrel must NOT gate the hand-clear: it is optional loot, and making it block progression would // turn "decide whether that is worth a shot" into a chore every time one is on court. const blocksClear = z => !'rmo'.includes(z.variant); export function splatPoint(side, quiet = false) { if (G.drill) return; // the drill keeps its own round count — see drill.js. Routing it through here // would arm the barrel/machine/boss schedule inside a mode that has no wave. // The duel is one clean tennis point and resolveInterlude is its ONLY exit. A machine or leftover // killed mid-duel used to score here, and a game banked mid-duel re-entered proceedAfterClear — // which can start a boss, a card, or a horde while the duel still owns the court. if (G.interlude) return; if (side === 'P' && ++G.killCount < TUNE.killsPerPoint) return; // K kills bank one point; the rest just splatter if (side === 'P') G.killCount = 0; const gameOver = advancePoint(side); if (gameOver) { G.runGames++; // the schedule calls a DUEL, not a tin: it comes round on its own so there's always a chance // at a weapon, but you still have to win the point to be handed one. Fires at the next clear. // THE STORY's chapter has no duel, and its two bosses are its own: the mid-boss after game 5 // (the checkpoint) and the act's boss after game 10. The arcade schedule below never runs there. const chap = G.story && G.story.part === 'chap'; if (chap) { const rel = G.runGames - G.story.base; if (rel >= STORY.length) G.storyBoss = 'end'; else if (rel === STORY.midAt && !G.story.midDone) G.storyBoss = 'mid'; } else if (G.runGames === TUNE.duelFirst || (G.runGames > TUNE.duelFirst && (G.runGames - TUNE.duelFirst) % TUNE.duelEvery === 0)) G.duelDue = true; // ...and every bossEvery games the schedule calls a CHAPTER BOSS. Like the duel it fires from // proceedAfterClear(), the one beat where the court is guaranteed empty, and it outranks // everything else there — a boss must not walk on into a standing wave. if (!chap && G.runGames % TUNE.bossEvery === 0) G.bossDue = Math.max(G.bossDue, Math.floor(G.runGames / TUNE.bossEvery)); // never backwards; a // long stretch with no clear skips a chapter rather than queueing one, which is intended voice(side === 'P' ? 'game_set_massacre' : 'game_opponent', 0.5); // game-win lines always speak if (side === 'O') aside('ump_scoreline', 1.4); // they took a game: he would like that noted if (side === 'P') cheer(2.2, 0.5); else aww(0.85); // your game = applause, theirs = a groan const i = side === 'P' ? 0 : 1; if (score.games[i] >= 6) { G.sets[i]++; score.games = [0, 0]; G.afterClear = 'set'; } // set banked; a horde follows the card else if ((score.games[0] + score.games[1]) % 2 === 1) G.afterClear = 'changeover'; // real rule: after odd games else G.afterClear = 'game'; // even game: a beat, then the next wave G.clearWinner = side; // The racket zombie is DEMOTED: he used to appear on every single game you won, which is exactly // why he read as "the boss" and why a real boss would not have stood out next to him. He is an // occasional opponent now — every racketEvery games — and the chapter boss is the wall. if (side === 'P' && G.runGames % TUNE.racketEvery === 0 && !zombies.some(z => z.variant === 'r') && !G.bossFight) spawnRacketZombie(); // the rogue ball machine gets wheeled out on the same kind of schedule, capped on court if (side === 'P' && G.runGames >= TUNE.machEvery && G.runGames % TUNE.machEvery === 0 && zombies.filter(z => z.variant === 'm').length < machCap() && !G.bossFight) spawnMachine(); // ...and a weapon barrel on its own schedule — which TIGHTENS as the run goes on (barrelRamp), // because the late game is where the rack has to carry you. Still one at a time: two glowing // pink things on court would out-shout everything the palette uses to say "this one is dangerous". if (side === 'P' && G.runGames % barrelGap() === 0 && !zombies.some(z => z.variant === 'o') && !G.bossFight) spawnBarrel(); // The wave does NOT auto-collapse anymore: the survivors stay and must be cleared BY HAND before // play advances — only no NEW shamblers spawn. When the last one is down, proceedAfterClear() // fires (see update()). If the winning shot already emptied the court, proceed at once. if (zombies.some(blocksClear)) { G.clearing = true; ball.active = false; G.flash = { text: 'CLEAR THE COURT', t: 1.8 }; G.phase = 'over'; G.overT = TUNE.gameBeatT; // a short beat to register the game, then the serve loop resumes for the mop-up } else proceedAfterClear(); } else if (!quiet || G.idleT - G.lastScoreT > 2) { voice(scoreKey(), 0.45); G.lastScoreT = G.idleT; if (side === 'P' && !quiet) cheer(0.7, 0.25); } } // (proceedAfterClear and every start*/end* mode transition live in beats.js — one file owns who // has the court. splatPoint above only ARMS the schedule latches; beats.js decides when they fire.) // ---- the courtside rack. RACK_MAX + 1 slots, and only slot 0 is fixed: it is BALL (infinite), or // an overflow weapon riding it temporarily. Slots 1..RACK_MAX are what you have picked up, in order. // EVERYTHING READS THROUGH THESE FOUR, so nothing else has to know how the rack is stored — and // nothing else may hardcode how MANY slots there are. `slotOf` exists because endBurst did exactly // that ("[0,1,2,3,4].find(...)"), so the moment the rack grew past five, a burst weapon sitting in // slot 5..9 could never be found and never spent: beacon, flamer and rocket sat at their pickup // count forever. One accessor, one place that knows the range. const slotEntry = i => (i === 0 ? G.temp : G.rack[i - 1]) || null; export const slotId = i => slotEntry(i)?.id || (i === 0 ? 'ball' : null); export const slotStock = i => { const e = slotEntry(i); return e ? e.n : (i === 0 ? Infinity : 0); }; const slotOf = id => { // -1 if you are not carrying it for (let i = 0; i <= RACK_MAX; i++) if (slotId(i) === id) return i; return -1; }; export function selectWeapon(i) { if (G.interlude) return; // the sudden-death duel is played under tennis rules — plain balls only // A HORDE is the one place the rack stays locked: your finger is steering the man, the racket // fires itself, and there is no gesture left to spend on a tin. Everything else can be switched // out of, including mid-burst — see below. if (G.horde) return; if (slotStock(i) <= 0) return; // dud: nothing in that slot (slot 0 is only ever empty if a spent // A BURST IS NOT A COMMITMENT. Committing to a burst tin used to lock the rack for its whole // window — eleven seconds of beacon lobs while a late-game crowd walks into you is a death you // can watch coming and do nothing about, which is exactly what Oliver hit. Tapping another slot // ABORTS it: the tin is spent (you did fire it) and the serve loop comes straight back, the same // handover updateZombies does when the window ends on its own. Both input paths land here — the // rack tap and the 1-0 keys — so the abort lives here rather than in either of them. if (G.mgT > 0) { // ...but spending a tin can EMPTY it, and spendSlot splices an empty entry out, which shifts // every slot above it down one. A tap is at a slot; what you meant was the weapon you could see // in it. So resolve to the id first and look it up again afterwards — otherwise aborting a // beacon in slot 1 to reach the rocket in slot 4 hands you whatever slid into slot 4. const want = slotId(i); G.mgT = 0; endBurst(); setupPoint(); const j = slotOf(want); i = j >= 0 ? j : 0; // the tin we just spent WAS the one tapped: fall back to BALL } G.weapon = i; // temp has just been cleared, and then it is BALL again) sfx('weapon-click2.mp3', 0.45); // a real mechanical click, quiet — switching is audible, not loud haptic('select'); // ...and it is felt, which is what a physical rack would do } // Grant a weapon and equip it. Tops up what you already carry, else takes a free slot, else rides // slot 0 as `temp` — Oliver's rule: a fifth is always allowed, because a barrel arms on the spot and // is spent quickly, so refusing it would mean a barrel you shot did nothing. export function grantWeapon(id, n = WEAPONS[id].tin) { const have = G.rack.find(e => e.id === id); if (have) { have.n = Math.min(TUNE.wCap, have.n + n); G.weapon = G.rack.indexOf(have) + 1; } else if (G.rack.length < RACK_MAX) { G.rack.push({ id, n }); G.weapon = G.rack.length; } else if (G.temp?.id === id) { G.temp.n = Math.min(TUNE.wCap, G.temp.n + n); G.weapon = 0; } else { G.temp = { id, n }; G.weapon = 0; } return G.weapon; } // Spend one from a slot, and hand you back plain balls when it runs dry. The temp weapon does not // leave an empty dimmed slot behind the way a rack entry does — slot 0 simply becomes BALL again. function spendSlot(i) { const e = slotEntry(i); if (!e) return; if (--e.n > 0) return; // Spent, so it LEAVES — a slot is for what you are carrying, and an exhausted weapon sitting in // one is a quarter of the rack you can never pick anything up into. (The old fixed rack kept its // empty slots dimmed, which was right when the slot meant "BOMB goes here" and is wrong now.) if (i === 0) G.temp = null; // slot 0 goes back to being the infinite ball else G.rack.splice(i - 1, 1); G.weapon = 0; } // the weapon ladder: each delivery opens the next tin in the cycle and ADDS it to the rack // (capped per type at TUNE.wCap) — old stock is kept, so you can bank a bomb for the horde // instead of spending it now. G.weapon picks which tin the next serve throws. const TINS = ['bomb', 'triple', 'fire', 'gun']; // the duel ladder, in the order it hands them out // THE WEAPON LICENCE (the leagues): the league licenses a weapon, the run's `from` gate paces // it — BOTH must hold (config.TIN_LEAGUE; barrels apply the same licence in pickBarrelWeapon). // Pure and exported so the suite can pin the pot. export const tinLadder = () => [...TINS, ...Object.keys(BARREL_WEAPONS).filter(w => G.runGames >= BARREL_WEAPONS[w].from && prof.lg >= (TIN_LEAGUE[w] || 0))]; export function deliverTin() { // WALK the ladder to the next rung you have room for, rather than cycling blindly onto one that // is already at wCap. Blind cycling meant `grantWeapon` clamped the top-up to a no-op and a won // duel silently handed you nothing — and past about the thirteenth duel three rungs of four are // full, so most late-run duels paid out in literally nothing. Oliver: "later in the game a won // duel does not give you weapons anymore." const stock = id => { const s = slotOf(id); return s < 0 ? 0 : slotStock(s); }; // rack OR the temp slot // The barrel weapons are RUNGS OF THE SAME LADDER, not a fallback. They used to be paid out only // once all four tins hit wCap — and now that wCap is effectively no cap that branch would never // fire again, so a late duel could only ever hand you a fifth bomb. Unlocked ones only, so this // still cannot hand you a rocket in game three. const ladder = tinLadder(); let id = null; for (let k = 0; k < ladder.length; k++) { const cand = ladder[(G.deliveries + k) % ladder.length]; if (stock(cand) < TUNE.wCap) { id = cand; G.deliveries += k + 1; break; } } // Every tin AND every barrel weapon full is a real state and not a bug, so say so plainly instead // of miming a handover that cannot happen. No boy, no umpire line — nothing was delivered. if (!id) { G.flash = { text: 'RACK FULL', t: 1.1 }; return; } const wasPlain = G.weapon === 0 && !G.temp; // never yank you off a deliberate PLAIN (banking) // league 5 duels pay DOUBLE tins — the top of the club tips well (config.TIN_DOUBLE_LEAGUE) const tinN = WEAPONS[id].tin * (prof.lg >= TIN_DOUBLE_LEAGUE ? 2 : 1); grantWeapon(id, tinN); if (wasPlain) G.weapon = 0; sendBoy(); voice('new_balls', 0.6); aside('ump_new_balls_dark', 2.0); // "...and a new opponent, apparently." // THE PRIZE GETS ITS OWN CARD: the tin's picture, its name, and how many. Nothing else — the // first-sight MODAL is gone (a popup that freezes a live court mid-run costs runs) and so is the // prose that replaced it. You have just won a point; you want to know what landed and how much. G.card = { title: WEAPONS[id].label, sub1: '×' + tinN, icon: WEAPONS[id].icon, color: UI.warm, t: TUNE.tinCardT }; } // ---- TEST MODE, the back room's WEAPON TEST (five taps on the splash logo — it used to be a // triple-tap on the scoreboard). A sandbox for trying the new content without playing twenty games // to reach it: a steady medium wave and EVERY weapon on the rack, topped back up as you spend it. // It does NOT stop the run — games, duels and bosses all still happen underneath; it only overrides // the difficulty dials and keeps the rack stocked. export function toggleTestMode() { G.testMode = !G.testMode; if (!G.testMode) { G.flash = { text: 'TEST MODE OFF', t: 1.4 }; return false; } if (G.mode === 'tennis') forceZombies(); // no zombies to test against in a tennis match stockTestRack(); G.flash = { text: 'TEST MODE', t: 1.6 }; showModal({ title: 'WEAPON TEST', color: UI.pink, body: ['A STEADY CROWD, MEDIUM SPEED', 'EVERY WEAPON, KEPT FULL', '', 'IT RUNS UNTIL THE RUN DOES'] }); return true; } // EVERY weapon on the rack at full stock — the four tins as well as the four barrel weapons, which // is eight of the nine slots. It stocked only the barrel weapons before, on a screen called the // weapon test. Called on entry and then kept topped up in update, so a burst you commit (which // spends the whole tin) is available again a moment later. function stockTestRack() { for (const id of Object.keys(WEAPONS)) { const have = G.rack.find(e => e.id === id); if (have) have.n = Math.max(have.n, WEAPONS[id].tin); else if (G.rack.length < RACK_MAX) G.rack.push({ id, n: WEAPONS[id].tin }); } } function tickTestMode(dt) { if (!G.testMode) return; stockTestRack(); // no barrels: the rack is already full of everything they would hand you, and } // one rolling across the sandbox was just something else to look at // (startDuel, startBossFight/endBossFight, the deadlock watchdog and resolveInterlude live in // beats.js — see its header for the one-owner rule.) // retire the splatter ball; a running changeover's long beat must not be clobbered short export function spentBall() { ball.active = false; G.phase = 'over'; if (!G.changeover) G.overT = TUNE.splatterOverT; } // ---- THE SIDESTEP. Free walking is 'rally'-only (see the movement block) and that is staying — // but it left you welded to the spot through a whole boss fight, because a boss is fought from the // serve stance and the one finger is already spent on the slingshot. So: a horizontal swipe is a // committed step, not a joystick. It integrates OUTSIDE the movement block so it works in every // phase, and it carries invulnerability, which is the only reason to make one. export function dodge(dir) { if (G.mode !== 'splatter' || G.modal || G.dodgeCd > 0 || G.dodgeT > 0) return; G.dodgeDir = dir; G.dodgeT = TUNE.dodgeT; G.dodgeCd = TUNE.dodgeCool; G.dodgeI = TUNE.dodgeIFrame; dustPuff(P.x, P.z, 6); // the committed step kicks up the court — it also marks where the i-frame began G.idleHintT = 0; // ...and the apron demo goes away, like any other gesture. The touch path nulls // G.aim to fire this, and the demo's own guard only watches G.aim. P.facing = dir; // ...but a swing in flight keeps its pose. You can step out of the way mid-shot and the shot still // lands (swingT is untouched); clobbering the pose only threw away the animation for it. if (P.pose !== 'swing' && P.pose !== 'serve') { P.pose = 'run'; P.poseT = 0; } swish(0.22); } // How far out he may stand. P_BOUNDS.x is 6.5 m, but at the baseline in portrait the camera only // shows about ±4 m either side of the centre line, so walking to the bound put him clean off the // edge. Floored at the sideline so every legal tennis ball stays catchable — the court is never out // of bounds. Shared by the movement block and the dodge; both were inline in the former. const xVisible = () => Math.min(P_BOUNDS.x, (W / 2 - 14) / (TUNE.camF / (TUNE.camZ - P.z))); const xLimit = () => Math.max(xVisible(), COURT.W / 2); // (startHorde lives in beats.js) // Back to a fresh run WITHOUT reloading the page. The reload was the honest reset, but it also tore // down the AudioContext, so the menu theme restarted from silence every time you died — and the menu // is supposed to pick up the track that was already playing over the death summary. Every mutable // bag has a pristine copy (state.resetState) and the two modules with timers of their own put them // back themselves, so nothing here has to know the shape of any of it. export function restartRun(keepNet = false) { recordEvent('run_end', { outcome: G.done?.won ? 'won' : G.mode === 'eaten' ? 'lost' : 'abandoned' }); // netplay: resetState wipes G.net below; the socket must not outlive the run it carried — unless // the REMATCH is what asked for the reset, in which case the same pairing carries the next one if (!keepNet) netClose(); resetZombies(); resetInput(); resetState(); setupPoint(); beginRun(); // ...and the stats bag, which is the only state that does not live on G beginRunXp(); // ...and the profile's run delta, same rule (the xp itself is already banked) } export function forceZombies() { if (G.mode === 'splatter') { startHorde(); return; } // in splatter the cheat forces a horde wave if (G.mode !== 'tennis') return; ball.active = false; startTransition(); } // -------------------------------------------------------------- transition ---- // You won your first game. The applause dies. The match... continues. function startTransition() { G.mode = 'transition'; G.transT = 0; G.tFlags = {}; G.everTransitioned = true; G.overT = 9999; // hold the point flow; the timeline below releases it applause(0.7, 0.5); // the clap that gets cut off G.serveNum = 1; } function transition(dt) { const t = (G.transT += dt); const once = (key, at, fn) => { if (t >= at && !G.tFlags[key]) { G.tFlags[key] = true; fn(); } }; once('quiet', 1.8, () => voice('quiet_dark', 0, 0.82)); // THE CAMERA HAS TO BE THERE BEFORE IT HAPPENS. The zoom used to start ON the turn — and the // curve render applies is `sin(p*PI)`, which is ZERO at p=0, so the punch-in only reached full // magnification half a second AFTER he had already changed. You watched the aftermath every time. // It starts at 1.8 with T 3.2 so the peak (p=0.5) lands exactly on the turn at 3.4, holds through // it, and pulls back out as the mob walks on — which becomes the reveal of what else is coming. once('closein', 1.8, () => { G.zoom = { x: O.x, z: O.z, k: 2.1, t: 0, T: 3.2 }; }); // ...and the turn itself is a sprite SWAP, one frame to the next, with nothing to sell it. So it // lands on a beat: the world stops dead for a moment (hitstop), the screen flashes, he comes // apart and puts himself back together wrong. The freeze is what makes a swap read as an EVENT // rather than as a glitch — the eye is given something to blame the change on. once('turn', 3.4, () => { G.hitstop = 0.16; // the world holds its breath haptic('heavy'); // ...and the hand feels the world stop G.boomFlash = 0.16; G.shake = { t: 0.7, mag: 7 }; shriek(); splat(0.9); boom(); for (let k = 0; k < 40; k++) parts.push({ // he bursts, and what stands up is not him x: O.x, y: 0.5 + Math.random() * 1.5, z: O.z, vx: (Math.random() * 2 - 1) * 3.5, vy: 1 + Math.random() * 4, vz: (Math.random() * 2 - 1) * 3.5, t: 0.5 + Math.random() * 0.7, dark: Math.random() < 0.45 }); gore.push({ x: O.x, z: O.z, r: 1.1, a: 0.9 }); spawnZombie(O.x, O.z, 'c'); G.hideO = true; // the opponent... turns }); once('mob', 4.8, () => { spawnZombie(-3.5, -1.6, 'a'); spawnZombie(2.5, -2.1, 'b'); G.shake = { t: 0.5, mag: 6 }; }); once('seated', 5.4, () => voice('remain_seated', 0, 0.95)); once('go', 8.2, () => { enterSplatter(); G.shake = { t: 0.7, mag: 9 }; // the floor goes out from under the match G.flash = { text: 'THE MATCH HAS CHANGED', t: 2.0 }; }); } // WHERE THE SPLATTER MODE ACTUALLY BEGINS. Shared by the transition's last beat and by the gore // mode's own start, so the two doors cannot land the player in two slightly different games. // NO modal here — nobody read it (Oliver's testteam, verbatim). The ghost hand teaches the pull // instead: the idle clock is primed so the apron demo starts miming IMMEDIATELY rather than after // the usual beat of inaction, and it keeps coming back whenever the player stalls. function enterSplatter() { G.mode = 'splatter'; G.everTransitioned = true; score.server = 'P'; setupPoint(); voice('play', 0.4, 0.9); zombieMusic(); G.idleHintT = TUNE.pullHintAfter; } // THE GORE MODE STARTS IN THE GORE (Oliver, 2026-08-15: "the splatter gore should not have the // normal tennis intro anymore, we should just start right in splatter mode"). The prologue was a // game of real tennis you had to win before the mode you picked would begin — fine as a reveal the // first time, a toll booth every time after it. The transition itself is NOT deleted: it is still // what `forceZombies` plays, it is still the cinematic, and `everTransitioned` above keeps it from // firing behind us. What is gone is it standing between the menu and the mode. // The turned opponent still opens the run — he is the man you came to play, and the mode's whole // premise is that he is already one of them. export function startGore() { if (G.mode !== 'tennis') return; enterSplatter(); spawnZombie(O.x, O.z, 'c'); G.hideO = true; G.shake = { t: 0.6, mag: 7 }; G.flash = { text: 'THE CLUB IS DEAD', t: 2.0 }; } export function fault() { // exported for net.js: the receiver's fault call re-arms the server's second serve recordEvent('fault', { serveNumber: G.serveNum }); if (G.serveNum === 1) { if (netJudging()) netSendFault(); // netplay: only the first-serve branch wires — the double // fault below goes out through pointTo as the point it is // "Fault. Second service." — the second half is the whole point of the call: a lone "fault" // never said that the player still had a serve coming (Oliver, 2026-08-22). voiceSeq, not two // voice() calls: the umpire is one man with one microphone and the second line would be DROPPED. G.serveNum = 2; voiceSeq(['fault', 'second_service']); G.phase = 'over'; G.overT = 1.4; sendBoy(); // ...and in a MATCH the body goes back on its receiving mark for the second serve, exactly as // matchReady puts it there for a first one — it is standing wherever it chased the fault to. // Not in netplay: the called ball is left rolling there (net.js), and matchReady's ball.active // = false would freeze it mid-roll on the far phone. if (G.match && !G.net) placeReceiver(); } // A DOUBLE FAULT IS A POINT, AND A POINT IS SCORED BY THE MODE THAT OWNS THE COURT. pointTo is // TENNIS scoring; the duel and the match settle points through resolveInterlude (blood, the fall, // the ladder) and a raw pointTo there would bank a number and skip all of it — the same seam the // interlude branch of onBounce is written around. Nothing reached this line from an interlude // before, because those modes had no fault in them at all. else if (G.interlude) { voice('double_fault'); resolveInterlude(other(score.server), 'double_fault'); } else pointTo(other(score.server), 'double_fault'); } export function inCourt(x, z) { return Math.abs(x) <= COURT.W / 2 + 0.02 && z >= -0.02 && z <= COURT.L + 0.02; } export function inServeBox(x, z) { const toFar = score.server === 'P'; const xOK = deuceSide() ? (toFar ? x <= 0.02 : x >= -0.02) : (toFar ? x >= -0.02 : x <= 0.02); const zOK = toFar ? (z >= COURT.svcFar - 0.02 && z <= COURT.netZ) : (z >= COURT.netZ && z <= COURT.svcNear + 0.02); return xOK && Math.abs(x) <= COURT.W / 2 + 0.02 && zOK; } // Would winning THIS point put a collapse (or the game-winning kill) on court? Mirrors // matchPoint's own wound math and advancePoint's game test — if the answer is no, there is // nothing for an early resolution to show, and the rally keeps its ordinary ending. function matchFallWillPlay() { const rz = zombies.find(z => z.variant === 'r'); if (!rz) return false; const cup = G.match.cup; const gameWin = score.pts[0] + 1 >= 4 && score.pts[0] + 1 - score.pts[1] >= 2; // a game win is only THE KILL when it ends the round — a cup mid-round game keeps the body if (gameWin) return !cup || cup.g + 1 >= cup.rounds[cup.round].need; // the same divisor matchPoint will use — the first version kept the deathmatch /4 and in the // cup it promised falls that matchPoint then didn't deliver (a route to the stale-latch lock) const div = 4 * ((cup && cup.rounds[cup.round].need) || 1); const after = Math.max(1, rz.hp - Math.ceil(rz.hpMax / div)); 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. // How much this body has LEARNED — THE READ's rung scale, 0 at the shambler, 1 at the champion. // One definition, because the read boost, the wrong-foot slow and the surprise whiff must all // answer "how smart is this one" identically or the twist pulls in three directions at once. const learnK = rz => G.match && !G.net ? Math.min(1, Math.max(0, (G.match.opp - 1) / (LADDER - 1))) : 0; // The Climb is a repeatable arcade run: less nursery at the bottom. Four extra progression // steps taper to zero at opponent eight; identities, territory tempo and cup onboarding stay put. // Share this across movement, intercept planning and returns so all four dials agree. const matchStep = () => { const n = G.match.opp - 1; return n + (!G.net && !G.match.cup && !G.match.train ? 4 * Math.max(0, 1 - n / 7) : 0); }; // THE LEGS HAVE A KNEE (2026-10-14). The ladder multiplied speed rung on rung with nothing on top: // 7.3 m/s at the RUNNER, 10.6 at the HEAD COACH, 15.7 at the CHAMPION — a sprinter's top speed and // 2.5x the player's ~6.2. Under step 1's geometry a late opponent at the net then covered every pass. // Up to legKnee nothing changes; past it only legSoft of the growth survives, so the order of the // ladder (and every like-for-like speed test) holds while the champion lands near ~7.5. Wrong-footing, // the read, placement and consistency are what climb past it now. const knee = v => v <= TUNE.legKnee ? v : TUNE.legKnee + (v - TUNE.legKnee) * TUNE.legSoft; const rzChaseSpeed = rz => knee(rzRawSpeed(rz)); const rzRawSpeed = rz => TUNE.racketSpeed * leaguePace() * (G.match ? (TUNE.matchSpeedBase + matchStep() * TUNE.matchSpeedPer) * ((rz.foe && rz.foe.speed) || 1) : 1 + TUNE.duelSpeedMax * duelSkill()) * (G.wasServe && ball.lastHit === 'P' ? TUNE.aiReturnDash : 1) // wrong-footed: a body mid-lean is slow to turn — THE READ's one-ball cost, and it lives HERE // so the intercept planner and the reach tests believe it too (a surprised champion plans like // a body that genuinely cannot get there, which is the whole point) * (G.match && G.match.surprised ? 1 - TUNE.surpSlow * learnK(rz) : 1) // ...and a net man who has been LOBBED turns and runs backwards: real legs do that slowly, and it is // what makes the lob the answer to the net (the planner believes it too) * (ball.lastHit === 'P' && ball.rx && ball.rx.lob ? TUNE.lobTurn : 1); // HIS plan, not the player's // 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 // chase speed — the same dials racketAI moves with — plus a full swing reach and a 0.6 m benefit // of the doubt. Returns true only when the rally is provably over. function matchOutOfReach() { const rz = zombies.find(z => z.variant === 'r'); if (!rz || ball.vz > 0) return false; const g = ball.g || TUNE.gravity; const t2 = Math.max(0.05, 2 * ball.vy / g); // time until the second touchdown const spd = rzChaseSpeed(rz); for (let i = 0; i <= 8; i++) { const t = t2 * i / 8; const bx = ball.x + ball.vx * t, bz = ball.z + ball.vz * t; if (Math.hypot(bx - rz.x, bz - rz.z) <= spd * t + TUNE.aiReach + 0.6) return false; } return true; } // The MID-FLIGHT version (Oliver: "the losing point could still be more anticipated"): the same // question asked while the ball is still in the air, over the piecewise track flight -> bounce -> // roll to the second touchdown. Same conservative margins; pred is predictLanding()'s exact spot. function matchOutOfReachFlight(rz, pred) { const g = ball.g || TUNE.gravity; const spd = rzChaseSpeed(rz); const vyl = ball.vy - g * pred.t; // vertical speed at touchdown const t2 = Math.max(0.05, 2 * (-vyl * bY()) / gAfter(g)); const vx2 = ball.vx * bXZ(), vz2 = ball.vz * bXZ(); const T = pred.t + t2; for (let i = 0; i <= 10; i++) { const t = T * i / 10; const bx = t <= pred.t ? ball.x + ball.vx * t : pred.x + vx2 * (t - pred.t); const bz = t <= pred.t ? ball.z + ball.vz * t : pred.z + vz2 * (t - pred.t); if (Math.hypot(bx - rz.x, bz - rz.z) <= spd * t + TUNE.aiReach + 0.6) return false; } return true; } function onBounce() { ball.bounces++; pock(0.35, 500); if (ball.scored) return; // a called ball still bounces and still sounds; it just cannot score twice // a deferred praise word that met no body lands with the ball — the DRAW was still perfect if (ball.praise) { praisePop(ball.praise, ball.x, 0.3, ball.z); ball.praise = null; } if (G.mode === 'splatter' && ball.bomb && ball.bounces === 1) { // a bomb that missed everyone still blows up explode(ball.x, ball.z); ball.bomb = false; spentBall(); return; } if (G.mode === 'splatter' && G.interlude) { // sudden death: the lines are LIVE again // A ball SPENT on a body ('X' — it wounded the racket zombie without killing him) gets no line // call in either direction: it is nobody's shot anymore. Without this case the P-or-not test // below read 'X' as HIS ball — bounce out and you were handed the duel, bounce twice in-court // and you LOST it for hitting him. Same rule as the dead-roller path in update(). if (ball.lastHit === 'X') { spentBall(); return; } // NETPLAY (death match): same rule as the tennis block below — the receiving phone is the only // judge of a flight; our outbound ball is picture and its verdict arrives as a message. if (G.net && ball.lastHit === 'P') return; if (ball.bounces === 1) { const okSide = ball.lastHit === 'P' ? ball.z < COURT.netZ : ball.z > COURT.netZ; // A MISSED SERVE BUYS A SECOND ONE, IT DOES NOT LOSE THE POINT (Oliver, 2026-08-22: "when // serving results in an out there should be a second service"). These modes had no service // rule at all: the first serve was judged as an ordinary ground stroke, so a serve into the // net or over the baseline handed the point away on the spot — in the match, that is a point // and blood for a first serve. It goes through the same fault() the tennis modes use, so the // second serve, the umpire's call and the double fault all come for free. // The BOX is deliberately still not enforced here (see the note in hitServe): a deep-but-in // serve stays legal in the match, which is the concession that mode was tuned around. if (!inCourt(ball.x, ball.z) || !okSide) { endFlight(); if (G.wasServe) fault(); else { resolveInterlude(other(ball.lastHit), 'out'); voice('out'); } } // THE FALL BEATS THE BALL (Oliver: "the zombie falls after the ball is lost although we know // this earlier — it could play out as ball lost BECAUSE of the falling"). A winner that just // bounced in and is provably beyond the body's reach has already decided the point, so it // resolves HERE: the collapse starts while the ball still rolls past, and the picture reads // "he fell, so it got by". Two deliberate limits: // - only when a FALL (or the game-winning kill) will actually play — an ordinary point // keeps its ordinary bounce-2 ending, or the ball would vanish mid-roll for nothing; // - conservative reach test — a borderline ball waits for the ordinary call. // THE LATCH IS SET BEFORE THE RESOLVE AND CLEARED WHEREVER A RALLY ARMS. Version one set // it after and a no-fall point's synchronous matchReady left it stale (the first lock); // version two argued after-set safe because "every admitted path defers matchReady" — and // the cup's mid-round game win promptly broke the argument (the second lock, "like freeze // from before"). No more ordering proofs: the latch goes up BEFORE, the resolution in // progress is exempted via G.matchEarly in resolveInterlude's swallow, and any path that // re-arms play clears it in matchReady. Stale by construction impossible. else if (G.match && !G.net && !G.match.ending && !G.match.decided && ball.lastHit === 'P' // !G.net: the early decide predicts the CPU's legs — a human on another phone is // not predictable, and the receiver is the only judge of this flight anyway && matchFallWillPlay() && matchOutOfReach()) { G.match.decided = true; // scope: the REMAINING ball events of this rally G.matchEarly = true; // startFall leaves the ball rolling; the swallow exempts us resolveInterlude('P', 'unreachable_after_bounce'); G.matchEarly = false; } } else if (ball.bounces === 2) { endFlight(); resolveInterlude(ball.lastHit, 'double_bounce'); } return; } if (G.mode !== 'tennis') return; // no lines, no faults — the umpire has other concerns // NETPLAY: THE RECEIVING PHONE IS THE ONLY JUDGE OF A FLIGHT. Our outbound ball is picture, not // evidence — the two sims are ~a latency apart, and this one would call a double bounce before // the other one's player had swung. Every call on this ball (box, line, second bounce) arrives // as a message instead; the ball rolls on harmlessly until it does. (See net.js.) if (G.net && ball.lastHit === 'P') return; if (ball.bounces === 1) { const wasServe = G.wasServe; G.wasServe = false; if (wasServe) { if (!inServeBox(ball.x, ball.z)) { endFlight(); fault(); } return; } // must land on the side opposite the hitter const okSide = ball.lastHit === 'P' ? ball.z < COURT.netZ : ball.z > COURT.netZ; if (!inCourt(ball.x, ball.z) || !okSide) { endFlight(); pointTo(other(ball.lastHit), 'out'); } } else if (ball.bounces === 2) { endFlight(); pointTo(ball.lastHit, null); } } // -------------------------------------------------------------- movement ---- // Where should a player run to? Simulate the ball forward and take the first point in // their half at hitting height that they can actually REACH IN TIME; otherwise chase the // deepest hittable point. This is the "every ball is catchable, timing is the skill" contract. // The assist's leg speed — the match floors it (matchAssist) because autoAssist is 0 wherever the // pointer is fine. Shared by the movement block AND intercept below: intercept uses it to decide // which points are reachable, and computing that with autoAssist alone meant a desktop match's // intercept thought NOTHING was reachable and sent the player home every time — he had legs but // his destination was always the baseline centre. // THE RALLY MODES SHARE ONE PLAYER FEEL (Oliver, 2026-08-14: "in classic tennis I tend to miss // balls way more often — zombie tennis is the good one"). The match's three contact helpers — // assist floor, matchReach, the short-ball chase — were tuned in the match and classic never got // them, so the same swing whiffed centimetres short there. Gore's opener keeps the old feel: its // tennis is a prologue, not the mode. export const rallyFeel = () => !!(G.match || G.classic); // THE LEVEL IS YOU, in the league (2026-08-29): the player's legs and contact window grow subtly // with the level — LEAGUE modes only (the climb, the cup, the training court). Classic keeps its // own difficulty picker, netplay is a fair human match, gore is untouched (its trio lives in // profile.bonus too, read elsewhere). Level 1 is factor 1: the shipped baseline is the floor. // THE PEER GIFT turns the walk-on's displayed level into a real promise. It exists only in the // canonical cup and its exact training rep: arcade remains a stable whole-club challenge. Fifteen // levels down is today's wall, parity gets the full gift, and being underlevelled is never a malus. export const peerK = () => G.match && !G.net && (G.match.cup || G.match.train) ? Math.max(0, Math.min(1, 1 - (foeLevel(G.match.opp) - levelAt(prof.xp)) / TUNE.peerGap)) : 0; export const legLevel = () => G.match && !G.net ? bonus().run * (1 + TUNE.peerRun * peerK()) : 1; export const winLevel = () => G.match && !G.net ? bonus().window * (1 + TUNE.peerWindow * peerK()) : 1; // legLevel rides INSIDE assistK, so the mover's legs and intercept's reachability can never // disagree about what those legs can get to (the rzChaseSpeed rule, player-side). 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; const st = styleOf(ch); // a body still RUSHING the net (serve and volley, a charge behind its approach) plays it as a // net man from wherever its run has got to — a serve return never counts ball.rx = { net: Math.abs(ch.z - COURT.netZ) < TUNE.netFrom || !!(ch.rushing && !G.wasServe), short: !G.wasServe && depth > TUNE.shortBall - (st && st.charge || 0) }; } return ball.rx; } // THE STYLES (step 2): how this body plays — the solo match's opponents only (see config.STYLES) const styleOf = ch => (G.match && !G.net && ch && ch.foe && STYLES[ch.foe.style]) || null; // ...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. // The PLAYER's double tap overrides all of that with a STANCE (P.post) that lasts until he changes it. const atNetHome = ch => ch.side === 'P' && P.post ? P.post === 'net' : (Math.abs(ch.z - COURT.netZ) < TUNE.netFrom && ball.retreat !== ch.side) || ball.approach === ch.side; const rallyHome = ch => ({ x: 0, z: atNetHome(ch) ? 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) { let 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) * bY(); vx *= bXZ(); vz *= bXZ(); g = gAfter(g); // the dip ends at the bounce } 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 at = t => ({ x: x + vx * t, z: z + vz * t }); const fall = at(Math.max(0, (vy + r) / g)), rise = at(Math.max(0, (vy - r) / g)); // a ball that will only come down past rallyBehind (a deep lob's high bounce) is taken on the RISE // instead — waiting for it would mean standing in the stand, and above CONTACT.maxY it is unplayable return short && vy > 0 || fall.z < -TUNE.rallyBehind ? rise : fall; } function intercept(side) { const ch = side === 'O' ? O : P; // G.diff is CLASSIC's difficulty chip (config.DIFFS) — null everywhere else, so only the // classic CPU speeds up or slows down. Same guard on every dial it scales. const spd = side === 'O' ? TUNE.aiSpeed * (G.diff ? G.diff.speed : 1) : TUNE.playerSpeed * assistK(); let { x, y, z, vx, vy, vz } = ball; let g = ball.g || TUNE.gravity; const dt = 0.01; // g switches to plain gravity at the simulated bounce (gAfter) const inZone = side === 'O' ? zz => zz <= ZONE_DEPTH : zz => zz >= COURT.L - ZONE_DEPTH; const home = rallyFeel() ? rallyHome(ch) : HOME[side]; const plan = rallyFeel() ? rxPlan(ch) : null; // a ball that will clear a net man's racket is a LOB — he turns and runs it down (racketAI's rule) if (plan && plan.net && (ball.z - ch.z) * vz < 0) { const tc = (ch.z - z) / vz; if (tc > 0 && y + vy * tc - 0.5 * g * tc * tc > (side === 'P' && rallyFeel() ? TUNE.smashMaxY : CONTACT.maxY)) { plan.net = false; plan.lob = true; } } const back = plan && !plan.net; // a baseliner lets it bounce // ...and only where his legs may go: the player is held on screen (xLimit), and since the dip ends at // the bounce a kicking ball drifts on sideways long enough to plan him out to x 6.5-8.7 — he ran to // the edge of the frame and watched it pass (his v369 log, 65.77 s and 84.26 s) const xl = side === 'P' ? xLimit() : 99; // ...and the PLAYER's reach is judged from where his chase BEGAN plus the time he has run since (P.chase), // not from where he stands this frame. Judged live, his own step moved the answer: a deep point became // reachable one frame and not the next, and the plan flipped 2.2 m every other frame (the shake). const o = side === 'P' && P.chase ? P.chase : { x: ch.x, z: ch.z, t: 0 }; // ...with HYSTERESIS: the flight is sampled every 10 ms against a 16.7 ms frame, so a candidate on a // margin passed one frame and failed the next as the grid slid — a low slice whose bounce peaks at ~0.46 m // against the 0.45 m floor below, measured flipping the plan 2.2 m every other frame. The point he is // already running to keeps runKeep of time and 6 cm of height slack. const near = (px, pz) => side === 'P' && P.goal && Math.hypot(px - P.goal.x, pz - P.goal.z) < 0.6; const can = (px, pz, i, k = 1) => Math.hypot(px - o.x, pz - o.z) / (spd * k) <= o.t + i * dt + (near(px, pz) ? TUNE.runKeep : 0); let fallback = null, deepest = null, anyHit = null, rise = null, nb = ball.bounces; // ...but the PLAYER lobbed at the net first looks for the overhead: the first point the lob comes down // into his smash band (smashMaxY) that his backpedal reaches in time. A short lob is smashed; one he // cannot get under is run down after its bounce, as before, and his reply is capped. // DECIDED ONCE, when the lob is read (plan.over, latched like the plan itself): re-asked every frame, his // backpedal toward the run-down brought the overhead into reach on the way, and every lob was smashed if (side === 'P' && plan && plan.over) return plan.over; const smashing = side === 'P' && plan && plan.lob && plan.over === undefined; 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 * bY(); vx *= bXZ(); vz *= bXZ(); nb++; g = gAfter(g); } if (smashing && nb === 0 && vy < 0 && y <= TUNE.smashMaxY - 0.2 && y >= TUNE.smashMinY && Math.abs(x) <= xl && can(x, z, i, TUNE.smashRun)) return (plan.over = { x, z, ti: o.t + i * dt }); // 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. // (up to rallyTop since the dip ends at the bounce: a kicking ball is taken at the top of its // bounce, chest high, rather than chased forward and volleyed — 40% volleys was the first cut) if (back && nb === 1 && i > 5 && y > (near(x, z) ? 0.39 : 0.45) && y < (plan.short ? TUNE.rallyHit + 0.6 : TUNE.rallyTop)) { const inB = (side === 'P' ? z <= COURT.L + TUNE.rallyBehind + (near(x, z) ? 0.1 : 0) : z >= -TUNE.rallyBehind) && Math.abs(x) <= xl; if (inB && can(x, z, i)) { if (plan.short || vy < 1) return { x, z, ti: o.t + i * dt }; rise ||= { x, z, ti: o.t + i * dt }; } } // 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 // losses. Remember the earliest reachable point ON HIS HALF where the ball is still at hitting // height: running forward onto the short ball is the tennis answer, and now it is the assist's. if (side === 'P' && rallyFeel() && !anyHit && i > 5 && z > COURT.netZ && y < 1.6 && y > 0.35) { const pt = { x, z: Math.min(24.5, z), ti: o.t + i * dt }; if (Math.abs(x) <= xl && can(pt.x, pt.z, i)) anyHit = pt; } 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), ti: o.t + i * dt }; const reachable = Math.abs(x) <= xl && can(pt.x, pt.z, i); 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 } } if (smashing) { plan.over = null; if (P.post === 'net') P.post = 'base'; } // no overhead in reach: he runs it down, and stays back // ...and a frame that finds NOTHING mid-chase holds the plan it had: a dying drop's bounce sits on the // 0.35 m floor, and every other frame without a candidate sent him home, 10 m away, and back again const hold = side === 'P' && P.chase && P.goal ? P.goal : null; const to = rise || fallback || deepest || anyHit || hold || home; // zone points first: meeting deep is still better // a ball no point of which he can reach still gets a run — to the nearest spot he is allowed to stand return Math.abs(to.x) > xl ? { x: Math.sign(to.x) * xl, z: to.z, ti: to.ti } : to; } // THE GOLD RING, TIMED (2026-10-14). The approach ring used to go gold at a DISTANCE (matchRingSweet // 2.7 m) — right for the ~20 m/s rally balls it was calibrated on, and too early for every slow ball // steps 1-3 brought in: a pusher's moonball, a lob, a drop, a deep ball braked by its bounce. A // gold-ring tap on a slow ball missed "30 ms after the window" (the swing log's first catch). Now the // cue rolls the ball forward to the moment it enters the player's real contact reach (the same reach, // height band and let-it-bounce rule trySwing applies) and is gold while a finger landing NOW centres // that moment in the swing window — whatever the pace. TUNE.ringTime 0 restores the distance ring. export function ringCue() { if (!ball.active || ball.lastHit !== 'O' || ball.vz <= 0) return null; let g = ball.g || TUNE.gravity; const dt = 1 / 120; let { x, y, z, vx, vy, vz } = ball, b = ball.bounces; // ...against where the player WILL be: the auto-run is still carrying him to its target at the pace // he is running now, and measuring against where he stands made the cue jump 50-180 ms a frame const sp = Math.hypot(P.vx || 0, P.vz || 0), tg = P.tgt; const togo = tg ? Math.hypot(tg.x - P.x, tg.z - P.z) : 0; for (let t = 0; t < 1.6; t += dt) { const volley = b === 0; const reach = TUNE.reach * (volley ? VOLLEY.reach : 1) * (rallyFeel() ? TUNE.matchReach : 1); const legal = y <= (volley && rallyFeel() ? TUNE.smashMaxY : CONTACT.maxY) && (y >= CONTACT.minY || vy > 0) && !(volley && y < COURT.netH && ball.rx && !ball.rx.net); const k = togo > 0.01 ? Math.min(1, sp * t / togo) : 0; const xl = xLimit(), px = Math.max(-xl, Math.min(xl, P.x + (tg ? tg.x - P.x : 0) * k)), pz = P.z + (tg ? tg.z - P.z : 0) * k; if (legal && Math.hypot(x - px, (z - pz) * 0.8) <= reach) { const win = TUNE.swingWindow * (volley ? VOLLEY.window : 1) * winLevel(); const sweet = TUNE.swingWindup + win / 2, band = win / 2 * TUNE.ringBand; return { eta: t, sweet, band, gold: Math.abs(t - sweet) < band }; } vy -= g * dt; x += vx * dt; y += vy * dt; z += vz * dt; if (y <= 0 && vy < 0) { y = 0; vy = -vy * bY(); vx *= bXZ(); vz *= bXZ(); g = gAfter(g); b++; if (b > 1) return null; } } return null; } // ---------------------------------------------------------- miss diagnostics ---- // Every player swing in a rally gets logged: HIT with quality, shot and how long the tap was, or // MISS with the gate that rejected it and the ball's closest approach — and every point ends with // a WON/LOST line naming why. Tap the scoreboard to see it; tap the log to COPY all of it (main.js). // Inspect live via T.G.missLog; the latest miss reason can ride the HUD (TUNE.missHud). // RALLY MODES since 2026-10-14 (Oliver: "bitte bau nochmal die Funktion ein die protokolliert warum // ich einen Ball nicht bekommen habe"): it was gated on G.mode === 'tennis', and the match runs as // 'splatter' under its interlude — so the Climb and the cup, the modes he plays, logged nothing. const r2 = x => Math.round(x * 100) / 100; const swingLog = () => G.mode === 'tennis' || rallyFeel(); export function logSwing(entry) { // exported for beats.js (the watchdog logs into the same ring) entry.at = r2(G.idleT); G.missLog.push(entry); if (G.missLog.length > 200) G.missLog.shift(); if (entry.result !== 'HIT' && entry.result !== 'WON') G.lastMiss = { text: entry.reason, at: G.idleT }; } // The line a POINT ends on, so a run of MISS/HIT lines reads as a story: who took it and why, and — // when it was lost with no swing at all — how close the ball came. Called by pointTo (classic) and // by resolveInterlude (the match), the two places every verdict passes. export function logPoint(side, reason) { if (!swingLog()) return; const mine = ball.lastHit === 'P', n = G.rallyNear; const why = reason === 'out' ? (mine ? 'your ball out' : 'their ball out') : reason === 'double_bounce' ? (mine ? 'bounced twice on their side' : 'bounced twice on your side') : reason === 'double_fault' ? 'double fault' : reason === 'dead_roller' ? 'ball died' : reason === 'passed_player' ? 'passed you' : reason === 'unreachable_after_bounce' || reason === 'unreachable_in_flight' ? 'called early: out of his reach' : reason === 'body_destroyed' ? 'body destroyed' : reason || 'unspecified'; const unswung = side === 'O' && !mine && n && !G.swung; logSwing({ result: side === 'P' ? 'WON' : 'LOST', reason: why + (unswung ? ` — never swung, it came within ${r2(n.d)}m at y=${r2(n.y)}m` : ''), ...(unswung ? { dMin: r2(n.d), y: r2(n.y), ballZ: r2(n.bz), px: r2(n.px), pz: r2(n.pz) } : {}) }); } function logWhiff() { const snaps = G.swingSnaps; G.swingSnaps = null; if (!swingLog()) return; if (!snaps || !snaps.length) { logSwing({ result: 'MISS', reason: !ball.active ? 'no live ball' : 'ball was yours — already hit' }); return; } const best = snaps.reduce((a, b) => (b.d < a.d ? b : a)); const inReach = snaps.filter(s => s.d <= s.r); let reason; if (!inReach.length) { // never in reach during the swing: was the swing mistimed, or the ball line simply wide? const tX = ball.active && ball.vz > 0.1 ? (P.z - ball.z) / ball.vz : -1; // time until ball reaches your depth const lat = tX > 0 ? Math.abs(ball.x + ball.vx * tX - P.x) : null; // how wide it will pass const nearD = G.rallyNear ? G.rallyNear.d : 1e9; // closest true pass so far this rally if (lat !== null && lat > best.r) reason = `ball line ${r2(lat)}m wide of you — run, don't swing`; else if (tX > 0) reason = `tap too early — ball still ${r2(best.d)}m out, arrives ${Math.round(tX * 1000)}ms after swing ended`; else if (nearD > best.r) reason = `ball passed ${r2(nearD)}m wide of you — never reachable from where you stood`; else reason = `tap too late — ball already gone (it came within ${r2(nearD)}m of you earlier)`; } else { const inWin = inReach.filter(s => s.t >= TUNE.swingWindup && s.t <= TUNE.swingWindup + s.w); if (!inWin.length) { const first = inReach[0], last = inReach[inReach.length - 1]; reason = last.t < TUNE.swingWindup ? `tap too late — ball already passing ${Math.round((TUNE.swingWindup - last.t) * 1000)}ms before swing opened` : `tap too early — ball arrived ${Math.round((first.t - (TUNE.swingWindup + first.w)) * 1000)}ms after window closed`; } else if (inWin.every(s => s.lb)) reason = 'waited for the bounce — still in the air below the tape (from the back you let it bounce)'; else if (inWin.every(s => s.y > CONTACT.maxY)) reason = `too high — ball at ${r2(best.y)}m overhead`; else if (inWin.every(s => s.y < CONTACT.minY)) reason = `too low — ball at ${r2(best.y)}m, past you`; else reason = 'unclassified — in reach, in window, in height band'; // should not happen } logSwing({ result: 'MISS', reason: reason + ` · ${P.flick ? 'flick' : 'tap'} ${P.tap || 0}ms`, dMin: r2(best.d), y: r2(best.y), tSwing: r2(best.t), ballZ: r2(best.bz), bounces: best.bounces, px: r2(P.x), pz: r2(P.z), ...(P.tgt ? { runTo: r2(P.tgt.x) + ',' + r2(P.tgt.z) } : {}) }); // where the auto-run was taking him } // THE FOLLOW-THROUGH (step 4 (b), 2026-10-14). A tap a little EARLY on a baseline ball starts the swing // while the ball is still in the air; the swing waits for the bounce (letBounce), and by then the // groundstroke window has run out — a clean whiff at ~70 ms early, while a tap 120 ms LATE still met // the ball. Before step 1 the same ball was volleyed in a 1.8x window, so it never came up. In the // rally modes the swing now carries on for swingTail after its window and meets a ball arriving there // at q 0.05: a weak short ball the opponent has to punish. Whether he does is what the ladder is. const swingTail = isAI => !isAI && rallyFeel() ? TUNE.swingTail : 0; // ...and the other half, without which the follow-through only made rallies endless: the CPU had NO // way to win a point. It returned a floater exactly like a drive, its whiff rate is near zero past // mid-ladder, and every point it took was a whiff of the player's — which no opponent dial touches, so // the ladder could not climb. A sitter (the player's ball under punishBelow) is now PUT AWAY with a // chance that IS the ladder: punishMin at the shambler to punishMax at the champion, classic by // difficulty. Rally modes, solo only. function punishes(ch) { if (!rallyFeel() || G.net || ch.side === 'P' || (ball.pow ?? 1) >= TUNE.punishBelow) return false; // the Climb's own progress (matchStep: its brisker bottom included), so a level-26 run is not filler const k = G.diff ? (G.diff.punish ?? 0) : G.match ? TUNE.punishMin + (TUNE.punishMax - TUNE.punishMin) * Math.min(1, matchStep() / (LADDER - 1)) : 0; return Math.random() < k; } function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) { // Contact happens if the ball is inside reach while the swing window is open. // Player volleys (pre-bounce) get wider reach + window: full-speed ball, no bounce cue. const volley = !isAI && ball.bounces === 0; // The MATCH plays with a longer racket. The flight recorder's third session: he ran onto every // ball (2.9-3.9 m per point) and swung every time, and the closest approach clustered at a // sampled 1.3-1.6 m — centimetres outside the 1.3 m post-bounce reach, point after point. The // assist can deliver you NEAR the ball but not INSIDE a phone-tuned reach; a rally mode lives or // dies on contact feeling fair, and the gore modes are untouched. const reach = isAI ? (G.diff ? G.diff.reach : TUNE.aiReach) : TUNE.reach * (volley ? VOLLEY.reach : 1) * (rallyFeel() ? TUNE.matchReach : 1); const win = TUNE.swingWindow * (volley ? VOLLEY.window : 1) * (isAI ? 1 : winLevel()); const t = ch.swingT; if (t < TUNE.swingWindup || t > TUNE.swingWindup + win + (volley ? 0 : swingTail(isAI))) return; // the tail meets a BOUNCED ball if (!ball.active || G.phase !== 'rally') return; // splatter: your OWN ball is fair game. It rebounds off the backstop and comes back at you — // volleying it is how you keep firing without walking it down. (The duel is tennis: one touch.) const own = G.mode === 'splatter' && !G.interlude && ch.side === 'P'; if (ball.lastHit === ch.side && !own) return; const dx = ball.x - ch.x, dz = ball.z - ch.z; // THE SMASH: the player's overhead reaches higher than a volley, out of the air, in the rally modes const overhead = !isAI && volley && rallyFeel(); if (Math.hypot(dx, dz * 0.8) > reach || ball.y > (overhead ? TUNE.smashMaxY : CONTACT.maxY)) return; // ...unless he only reached it TURNING AND RUNNING (a lob whose overhead was out of reach when he read // it): that one is met on the run, a defensive block like every run-down lob, however high const smash = overhead && ball.y >= TUNE.smashMinY && !(ball.rx && ball.rx.lob && !ball.rx.over); 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 // planned from the back, the swing WAITS for the bounce instead of a weak pick-up — but only while // the bounce will still land inside this window. If it will not, this is the last chance and the // low ball is dug out as a half-volley (the below-the-tape cap prices it): a swing a touch early // used to WHIFF here, which read as the game eating a good swing (Oliver's log, 2026-10-14). // The deadline is the window AFTER the bounce (the groundstroke one, shorter than the volley window // this swing is running on now) — judged against the long one, it waited and then expired on the // bounce frame (his v367 log, 8.98 s). if (letBounce()) { const gg = ball.g || TUNE.gravity, tb = (ball.vy + Math.sqrt(ball.vy * ball.vy + 2 * gg * ball.y)) / gg; if (t + tb < TUNE.swingWindup + TUNE.swingWindow * winLevel() + swingTail(isAI) - 0.03) return; } // quality: how centered in the window the contact lands const center = TUNE.swingWindup + win / 2; const off = Math.abs(t - center) / (win / 2); let q = Math.max(0.05, 1 - off); // ...WITHOUT A CLIFF in the rally modes (step 4, 2026-10-14). The line above runs from the centre, so // q was 1 inside perfectMs and 0.43 a millisecond outside it: a groundstroke's half-window is ~80 ms // and PERFECT owns 45 of them. The volley's 1.8x window used to hide that (q 0.69 at the edge); step // 1 made the rally groundstrokes, and a 30 ms early tap came back at q 0.6 (0.89 now). Same window, // same PERFECT, same whiff edge — only the step between them is gone. It does NOT move the ring bot's // win rate: points are decided by whiffs, not weak balls (see PROJECT.md, step 4's measurement). const p = TUNE.perfectMs / 1000, half = win / 2; if (rallyFeel() && !isAI && TUNE.qSeamless && half > p) q = Math.max(0.05, 1 - (Math.abs(t - center) - p) / (half - p)); if (Math.abs(t - center) * 1000 < TUNE.perfectMs) q = 1; if (t > TUNE.swingWindup + win) q = 0.05; // the follow-through: met, barely ch.facing = dx >= 0 ? 1 : -1; // forehand right (righty), backhand left → mirrored sprite // the LIVE swipe outranks the released one: since the crossing fires the swing (input.js), the // finger is usually still down at contact, and the vector under it NOW is the aim the player // means — the released flickAim only carries it when the ball arrives after the finger lifted. const flick = isAI ? null : (G.swipe || takeFlickAim()); // SMART AIM (classic difficulty or the solo match foe): with this probability the CPU plays the OPEN // COURT — the corner away from where you stand — instead of rolling a random lane. Random aim // sends a third of everything straight back to your racket; hunting the open corner is what // makes a hard CPU feel like it is PLAYING you. ax is an absolute court lane (see doHit). const smartChance = G.diff ? (G.diff.smart || 0) : G.match && !G.net ? (ch.foe?.smart || 0) : 0; // Keep the old random sequence for classic and for foes without smart aim. // THE SITTER IS PUNISHED: rolled first and only on a sitter, so every other ball keeps its old sequence const kill = isAI && punishes(ch); const smart = kill || isAI && (G.diff || smartChance) && Math.random() < smartChance; const aim = isAI ? { ax: smart ? (P.x > 0.6 ? -1 : P.x < -0.6 ? 1 : (Math.random() < 0.5 ? -1 : 1)) : [ -1, 0, 1 ][Math.floor(Math.random() * 3)], az: Math.random() < 0.25 ? -1 : 0 } : flick ? { ...flick } : { ax: axisX(), az: axisZ() * (ch.side === 'P' ? 1 : -1) }; // a copy: the shot marks it (smash) // THE STYLES (step 2): what this body DOES with the ball. A lob when you are at the net, a drop // when you hang back and it is inside its own court, a moonball as a habit — otherwise the rally // ball it always hit, carrying its spin. One roll, so the shares are exactly the table's. const st = isAI && !kill ? styleOf(ch) : null; if (st) { const atNet = Math.abs(P.z - COURT.netZ) < TUNE.netFrom, r = Math.random(); const inside = Math.abs(ch.z - COURT.netZ) < COURT.netZ - 1.5; if (atNet && r < (st.lob || 0)) { aim.az = 1; aim.soft = 1; } else if (atNet) { aim.ax = P.x > 0 ? -1 : 1; aim.pass = true; aim.spin = st.spin; } // the PASS: at the line, away from him else if (P.z > COURT.L - 0.3 && inside && r < (st.drop || 0)) { aim.az = 1; aim.soft = 0; } else if (!atNet && r < (st.loft || 0)) { aim.az = 1; aim.soft = 0.85; } else aim.spin = st.spin; } // A quality floor makes strong opponents sustain pressure, instead of only improving their // 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)); if (kill) q = Math.max(q, TUNE.punishQ); const q0 = q; // (the log names whichever cap below took it down) // 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. ...and a DROP dug out at the ankles after // its bounce comes back the same way: reaching it is not enough. Backspin balls only — any ball met // on its first frame in reach is often low, and capping those made every rally ball a sitter. // ...and SOFT backspin balls only (step 4): step 2's slicer hits every rally ball at spin 0.8, so this // capped every low contact against him, his own included — invisible until a sitter got punished. if (rallyFeel() && (ball.bounces === 0 && ball.y < COURT.netH || ball.soft && ball.spin < 1 && ball.y < TUNE.scoopY)) q = Math.min(q, TUNE.lowVolleyQ); // ...and a LOBBED net man who ran it down comes back on his heels: a defensive ball, not a reply. // That is the lob's real reward — rarely the winner itself, nearly always the point. if (rallyFeel() && ball.rx && ball.rx.lob && !smash) q = Math.min(q, TUNE.lowVolleyQ); // ...unless he got UNDER it if (kill && q >= TUNE.punishQ) aim.kill = true; if (smash) { aim.smash = true; praisePop('SMASH!', ball.x, ball.y, ball.z); } // a put-away only if no cap took it down (a low volley is a block) if (!isAI && swingLog()) { G.swingSnaps = null; G.rallyNear = null; const kind = aim.az > 0 && rallyFeel() ? (aim.mag === undefined || aim.mag >= TUNE.dropMax ? ' lob' : ' drop') : aim.az < 0 ? ' deep' : ''; const sway = swayOf(aim), lane = Math.abs(sway) > 0.15 ? (sway < 0 ? ' left' : ' right') : ''; const cap = q >= q0 ? '' : ball.rx && ball.rx.lob ? ' · capped: lobbed, on your heels' : ball.bounces === 0 ? ' · capped: volley below the tape' : ' · capped: dug out a drop'; // ...and WHICH WAY the timing missed: contact before the window's centre means the ball was // already there — the swing came LATE; after it, the swing came EARLY const off = Math.round((t - center) * 1000), timing = q0 >= 0.95 ? '' : off < 0 ? ` · late ${-off}ms` : ` · early ${off}ms`; logSwing({ result: 'HIT', reason: `q=${r2(q)}${smash ? ' SMASH' : kind}${lane}${volley && !smash ? ' volley' : ''}${timing} · ${P.flick ? 'flick' : 'tap'} ${P.tap || 0}ms${cap}`, d: r2(Math.hypot(dx, dz * 0.8)), y: r2(ball.y) }); } doHit(ch, q, aim); ch.swingT = -1; } // racket zombie (splatter mini-boss): a slimmed tennis CPU that holds the far court and returns // your SOFT shots as live balls. Splat-quality balls it won't rally — those wound its HP instead. function racketAI(rz, dt) { expirePose(rz, dt); rz.swingCool -= 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 // Solo tennis recovers during its own flight, at half chase speed. The serve pose finishes // first; dead balls and the gore racket keep their existing hold. const recovering = G.match && G.phase === 'rally' && ball.active && !ball.scored && !G.match.decided && !G.match.ending && ball.lastHit === 'O' && ball.vz > 0 && !(rz.serveHold > 0); // ...and while its own ball flies, is it RUSHING the net behind it? (serve and volley, an approach) if (recovering) rz.rushing = ball.approach === 'O'; // ...and the sprint velocity dies WITH the chase. This return kept the last chase's vx/vz on // the body, so the speed ghosts trailed a zombie that had already stopped ("teilweise bleiben // die doppelten ghosts länger sichtbar") — the trail must read as motion, never as residue. if (!incoming && !recovering) { rz.vx = 0; rz.vz = 0; if (rz.pose === 'run') rz.pose = 'idle'; return; } // 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 = incoming && ((G.match && ball.bounces === 0) || (ball.y > 0.35 && ball.vy < 3)) ? predictLanding() : null; // THE READ rides on aiRead: an EXPECTED ball (you repeated your habit) is read almost perfectly // — spam feeds the machine — and a SURPRISE ball collapses the read back toward the old pure // pursuit, which is exactly the wrong-footing aiRead exists to permit. Both scale with the rung // (learnK), so the shambler never learns and the champion is the one you beat by breaking a // habit you set on purpose. Dials in config's THE READ block; the streak lives in doHit. const learn = learnK(rz); const read = G.match && G.match.surprised ? TUNE.aiRead * (1 - TUNE.surpRead * learn) : G.match && G.match.expected ? TUNE.aiRead + (TUNE.readAnticipate - TUNE.aiRead) * learn : 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(); // 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; plan.lob = true; } } // ...and AT THE NET he needs a moment to read the pass: a volleyer reacts, he does not pre-know. // Without it a body with these legs covered every passing lane the instant the ball left. if (G.match) G.aiReactT += dt; // ...and a SOFT ball is read late from anywhere: until the strike it looks like any other swing, // which is the whole reason a drop or a lob wins points in the real game. Rallies are untouched. const reading = plan && (plan.net && G.aiReactT < TUNE.netReact || ball.soft && G.aiReactT < TUNE.softRead || ball.smash && G.aiReactT < TUNE.smashRead); 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.z : c ? c.z : netX !== null ? Math.max(rz.z, COURT.netZ - TUNE.netStand) : 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 && !reading) { // 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. // (the dials themselves live in rzChaseSpeed, which the rally-ending reach tests share) // (recovery walks at half pace — but a body charging the net behind its shot SPRINTS) const step = Math.min(rzChaseSpeed(rz) * (recovering && !rz.rushing ? 0.5 : 1) * 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'; // `face`, NOT `facing`. Zombies render off zb.face (drawZombie); `facing` is the PLAYER's field // (render flips a char on facing < 0 — the opposite convention, because the player art is drawn // facing RIGHT and the bodies are drawn facing LEFT). This line wrote a field nothing reads for a // zombie, so the match opponent's turn was left to the generic sticky-face line in updateZombies. // vx is kept so a body that is knocked down mid-chase can carry the run into its collapse. earnFlip(rz, 'face', Math.abs(dx) > 0.2 ? (dx > 0 ? 1 : -1) : 0, dt); rz.vx = dx / d * step / dt; // vz beside vx (drawZombie's speed ghosts read both), and MOVEMENT READS: a genuine sprint // kicks the surface up behind it — one puff per runDustEvery metres, the same per-court dust // the ball's bounce already throws, so the effect can never wear the wrong colour rz.vz = dz / d * step / dt; if (TUNE.trailMin && step / dt > TUNE.trailMin && (rz.dustAcc = (rz.dustAcc || 0) + step) > TUNE.runDustEvery) { rz.dustAcc = 0; dustPuff(rz.x, rz.z, 2, 0.6); } } else { rz.vx = 0; rz.vz = 0; if (rz.pose === 'run') rz.pose = 'idle'; } // THE WOUNDS PLAY. Score-is-the-body was a picture until now: it lost an arm and swung exactly // as far, lost its head and missed exactly as often. Both dials read the same racketStage the // SKINS do, so what you see it lose is what it loses. Match only — see config.matchWound. 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 && !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. const flub = (G.match ? TUNE.matchFlub * Math.pow(TUNE.matchFlubKeep, matchStep()) * (rz.foe?.flub || 1) * wnd.flub : TUNE.racketFlub * (1 - TUNE.duelFlubCut * duelSkill())) // THE READ: a wrong-footed racket reaches the ball late even when the legs got there — // the surprise ball ADDS whiff on top of the rung's own rate, scaled by how much this // body had actually learned. The champion's near-zero flub is exactly what this opens. + (G.match && G.match.surprised ? TUNE.surpFlub * learnK(rz) : 0); if (Math.random() < flub) { recordEvent('opponent_whiff', { chance: flub, chaseSpeed: rzChaseSpeed(rz), reach: TUNE.aiReach * wnd.reach }); if (swingLog()) logSwing({ result: 'THEM', reason: 'whiffed it' }); rz.swingCool = 0.5; // whiff — it hesitates (rarer per rung of the ladder) } else { const qCap = G.match && !G.net ? (rz.foe?.qMax ?? TUNE.racketQMax) : TUNE.racketQMax + TUNE.duelQBonus * duelSkill(); rz.swingT = TUNE.swingWindup + TUNE.swingWindow / 2; trySwing(rz, true, qCap); rz.swingT = -1; rz.swingCool = 0.3; } // the lean is SPENT at the racket, either way — matchReady clears it when a point ends unmet if (G.match) G.match.surprised = G.match.expected = false; } } // A BURST weapon isn't a ball — committing to one braces you at the baseline for a window (the same // mode a horde uses) and it fires ITSELF for `len` seconds while your finger only steers. G.mgT is // the mode timer, which is why the eighteen `!(G.mgT > 0)` guards across five files never changed. // There was briefly a third kind that fired one round per tap; Oliver read it as the weapon being // broken, so a barrel weapon is now either this or a serve. Nothing waits for a tap. function tryArmGun() { if (G.mode !== 'splatter') return false; const id = slotId(G.weapon), w = WEAPONS[id]; if (!w || w.kind !== 'burst' || slotStock(G.weapon) <= 0) return false; G.gunId = id; G.mgT = w.len ?? TUNE.mgPickupLen; // The tin is spent when the burst ENDS, not when it starts. Spending on commit removed the rack // entry immediately, so the countdown bar — which draws on the slot holding G.gunId — had no slot // to draw on and every burst weapon ran with no progress bar at all. Oliver spotted it on the // flamethrower; it was true of all four. G.mgCool = 0; ball.active = false; G.phase = 'rally'; P.pose = 'idle'; P.poseT = 0; P.swingT = -1; P.z = HOME.P.z; // brace at the baseline for the burst (finger steers x only) G.flash = { text: w.label, t: 1.0 }; return true; } export function serveToss() { // A NEW WIND-UP ENDS THE LAST SHOT'S FOLLOW-THROUGH. Without this he latched: hitServe sets // pose 'serve' with poseT 0, that only expires after POSE_TIME, and the pull frame is only drawn // from 'idle' — so firing faster than 0.38 s reset the timer before it could ever expire and he // was welded into p_smash. Racket permanently up, balls appearing out of it, no wind-up at all, // which is precisely the late-game rapid-fire look. Clearing it here restores pull/smash/pull. if (score.server === 'P' && P.pose === 'serve') { P.pose = 'idle'; P.poseT = 0; } if (score.server === 'P' && G.phase === 'pretoss' && !ball.active) tossBall(P); } // The burst is over: NOW the tin is spent. Called from updateZombies when mgT runs out. export function endBurst() { const id = G.gunId; G.gunId = null; if (!id) return; const slot = slotOf(id); if (slot >= 0) spendSlot(slot); } // The laser fired from a TAP (the keyboard/two-stage path). The drag path solves its own direction // and goes through hitServe; this one has no arc to read a lane off, so it takes the held flick aim // if there is one and otherwise fires straight up-court. function tryLaser() { if (G.mode !== 'splatter' || G.interlude) return false; const w = WEAPONS[slotId(G.weapon)]; if (!w || !w.beam || slotStock(G.weapon) <= 0) return false; const flick = takeFlickAim(); const dx = flick ? flick.fx : 0, dz = flick ? -Math.abs(flick.fy) : -1; spendSlot(G.weapon); fireBeam(dx, dz || -1); ball.active = false; G.phase = 'over'; G.overT = 0.5; P.pose = 'serve'; P.poseT = 0; return true; } // release of the slingshot aim: an instant serve to the reticle (quality fixed — placement // is this serve's power; the toss ritual keeps the PERFECT-timing power ceiling) export function dragServe(aim) { if (G.mode !== 'splatter' || score.server !== 'P') return; if (G.interlude || G.horde || G.mgT > 0 || G.changeover) return; // the duel serves like tennis, not the reticle if (G.phase === 'over') { ball.active = false; setupPoint(); } // mid-beat pull: arm the next shot now if (G.phase !== 'pretoss' && G.phase !== 'toss' && G.phase !== 'rally') return; if (tryArmGun()) return; if (aim.mag < TUNE.pullMin) return; // released without really drawing: cancel, nothing spent // Firing while the last ball still flies is fine again — the brake is the pull itself, not the // rally clock. This is what lets the drag mean ONE thing at all times: a second gesture that // walked the player around between shots was the confusing part, not the fire rate. if (ball.active && G.phase !== 'toss') // the old ball flies on as a ballistic clone extras.push({ x: ball.x, y: ball.y, z: ball.z, vx: ball.vx, vy: ball.vy, vz: ball.vz, pow: ball.pow || 0.5, t: 2.5, g: ball.g, wid: ball.wid }); ball.active = true; ball.collected = false; ball.bounces = 0; ball.x = P.x + 0.12; ball.z = P.z; ball.y = 2.2; ball.g = TUNE.gravity; // over his head, see tossBall // a full pull is quality 1.0, but only a pull that STOPS at the top of the draw is praised hitServe(P, pullQuality(aim.mag), aim, pullPerfect(aim.mag)); } // The swish cooldown, scaled by how crowded you are. Oliver died on every game-20 horde: the wave // grows with hordeGrow but the panic button was a flat 1 s, so past a certain density it simply could // not keep up. He picked "melee fires more frequently with horde density" over shrinking the horde. function meleeCooldown() { let near = 0; for (const zb of zombies) if (zb && Math.hypot(zb.x - P.x, zb.z - P.z) < TUNE.meleeCrowdR) near++; // the FLOOR rides the level (the leagues: the level is you) — the relaxed ceiling does not return Math.max(TUNE.meleeCoolMin * bonus().melee, TUNE.meleeCool / (1 + Math.max(0, near - 1) * TUNE.meleeCrowd)); } export function keyPressed(code, lead = 0, flick = false) { // THE SPRITE BENCH takes the key before anything else reads it: it is a back-room art test with no // ball and no scoring, so every other meaning a key has here is meaningless inside it. if (G.bench) return benchKey(code); // Dead men toss no balls. The restart tap lives in main.js (maybeRestart); everything below — // melee, parry, and especially the pretoss branch — must not run while the death animation plays, // or a panic tap conjured a ball that hung frozen midair over the corpse (update early-returns // in eaten mode, so a tossed ball never got a single frame of physics). if (G.mode === 'eaten') return; // Desktop sidestep: an arrow/A/D EDGE outside 'rally', where the walk axes do nothing anyway. // Inside a rally the same keys still walk, so one key never means two things at one moment. if (code === 'ArrowLeft' || code === 'KeyA' || code === 'ArrowRight' || code === 'KeyD') { if (G.phase !== 'rally') dodge(code === 'ArrowLeft' || code === 'KeyA' ? -1 : 1); return; } if (code !== 'Space') return; audio(); // unlock on first gesture // a swish cleaves adjacent shamblers — but it's on a cooldown now: the panic button, not the // default attack. A whiff costs nothing (nothing was in range), a connect locks it for meleeCool. if (G.mode === 'splatter' && G.meleeT <= 0 && !G.drill) { // no swish in the drill: it cleaves whatever // is adjacent, which is the mode picking // your targets for you // measure the crowd BEFORE the swipe — it kills everything in reach, so counting afterwards // counts the SURVIVORS and the cooldown barely moved no matter how swarmed you were const cool = meleeCooldown(); if (meleeSwipe(P.x, P.z, TUNE.meleeRange)) G.meleeT = cool; } // AN INCOMING SHOT OWNS THE TAP. The parry used to live behind the `phase === 'rally'` branch at the // bottom of this function — and in splatter you are in `pretoss` almost all of the time, so a tap // aimed at a machine's shot tossed the serve ball instead and the return was, in practice, // impossible. Oliver could not land one. A hostile ball in the air is the most visible thing on the // screen, so "the tap is a swing at it" keeps one gesture meaning one thing at any given moment. if (G.mode === 'splatter' && shots.some(s => Math.hypot(s.x - P.x, (s.z - P.z) * 0.8) < TUNE.parryArm * bonus().parry)) { P.swingT = 0; P.pose = 'swing'; P.poseT = 0; G.swung = true; swish(0.3); return; } if (G.horde || G.mgT > 0) return; // auto-fire modes: a tap only swishes, never serves if (G.changeover) return; // the breather is mandatory — the melee swish above still works if (G.interlude && G.interlude.introT > 0) return; // let the umpire finish announcing the duel if (G.net && (!G.net.ready || G.net.dead)) return; // netplay: no serving into an empty room // splatter: a tap/flick during the brief 'over' beat re-arms the next serve immediately if (G.mode === 'splatter' && G.phase === 'over') { ball.active = false; setupPoint(); } // splatter serve stays a TWO-STAGE ritual — toss up, THEN fire. It can only start from // pretoss WITH a ball in hand: one ball, and it has to come back before you fire it again. // A flick is the aimed one-motion variant (the reticle). The duel is exempt: it serves like tennis. if (G.mode === 'splatter' && !G.interlude && score.server === 'P' && G.phase === 'pretoss') { const flick = takeFlickAim(); if (flick) { dragServe(flick); return; } // dragServe handles GUN (burst) and the ball check } if (G.phase === 'pretoss' && score.server === 'P') { if (tryArmGun()) return; // a burst weapon selected: the gesture arms it, no ball // ...and a LASER never tosses one either. The ray IS the weapon, so a two-stage toss ritual was // throwing a ball into the air and then firing something else entirely. if (tryLaser()) return; 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. 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 / TUNE.apexFall); if (dy < TUNE.apexSnap) q = 1; G.wasServe = true; 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() }; // the dead zone is 44 px, well above the 28 px flick threshold: a slightly smudged strike TAP // must stay a safe serve — accidental sway was most of classic's "double fault very often", // because classic's line call enforces the service box (the match's only checks the court) const k = flick ? Math.max(0, Math.min(1, (flick.mag - 44) / 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; } // THE DOUBLE TAP IS HIS LEGS (2026-10-14, Oliver: "das Vorlaufen und Zurücklaufen sollte durch einen // Doppeltipp ausgelöst werden"). While his OWN ball is flying there is nothing to hit, so a tap there is // not a swing: two inside dblTap are the move — in to the net from the back, back from the net. An // incoming ball keeps every tap a swing, so the two meanings never share a moment. (v374 put this on the // flick's length; the shot and the run are separate decisions now.) if (rallyFeel() && G.phase === 'rally' && ball.active && ball.lastHit === 'P' && !flick) { if (G.moveTapT > 0) { G.moveTapT = 0; moveCall(); } else G.moveTapT = TUNE.dblTap; return; } if (G.phase === 'rally' && P.swingT < 0) { recordEvent('swing', { window: TUNE.swingWindow * winLevel(), assist: assistK(), lead }); // A TAP COUNTS FROM THE MOMENT THE FINGER LANDS (rally modes). A tap is only known to BE a tap // when the finger lifts — it could still turn into a flick — so it used to swing ~100-150 ms // after the player meant it. Inside step 1's groundstroke window that collapsed the contact // quality of a tap made exactly on the gold ring from 0.82 to 0.09 (Oliver, 2026-10-14: "trotz // gutem Timing nicht mehr bekommen"). input.js hands the touch's age in as `lead`, and the swing // clock starts that far along: the timing is judged from touch-down, the ball still has to be // there for the contact. // A FLICK is NOT back-dated by default (TUNE.flickLead 0): his v367 log showed every 62-64 ms flick // landing 76-92 ms EARLY once v366 timed flicks from touch-down — a flick is timed by its swipe, // not by the finger landing. The touch's age is still logged for both gestures. P.swingT = rallyFeel() ? (flick ? Math.min(lead, TUNE.flickLead) : lead) : 0; P.pose = 'swing'; P.poseT = 0; G.swung = true; P.tap = Math.round(lead * 1000); P.flick = flick; // ...and the log says which gesture, and how long it took to fire } } // In to the net, or back from it: the double tap's one meaning is "the other end of my half". At the net // (or already running in) it is the way back; anywhere else it is the way in. rallyHome reads the flags // while his ball flies, the run goes at full pace, and his reply is planned from wherever he has got to. // It is a STANCE, not one ball's run (Oliver on v376: "wenn man doppeltippt, geht er minimal vor"): his own // ball's flight covers 4-9 m of the 10 to the net, his next ball was met from mid-court, outside the net // zone, and the recovery after it walked him straight back to the baseline. P.post holds until the next // double tap, a lob he has to run down, or the point's end. function moveCall() { const goingIn = atNetHome(P); P.post = goingIn ? 'base' : 'net'; ball.approach = goingIn ? null : 'P'; ball.retreat = goingIn ? 'P' : null; popText(P.x, 0, P.z + TUNE.praiseBehind, goingIn ? 'BACK' : 'TO THE NET', UI.warm, true); } function expirePose(ch, dt) { if (ch.pose === 'swing' || ch.pose === 'serve') { ch.poseT += dt; if (ch.poseT > POSE_TIME) ch.pose = 'idle'; } } // A FACING FLIP MUST BE EARNED. The rolled run cycles carry a real racket, and a mirror flip // teleports it across the body — so the per-frame steering flutter the old racketless art could // hide reads as "the racket she is holding flashing left and right very fast" (Oliver, women's // tour, where the drawn racket is biggest). A flip now needs the wanted direction to PERSIST for // TUNE.faceFlip seconds. Every auto-steered body routes through here (the player's assist, the // tennis CPU, the match opponent); MANUAL input stays instant — a thumb reversal is intent. function earnFlip(ch, field, want, dt) { if (!want || want === ch[field]) { ch.flipT = 0; return; } if ((ch.flipT = (ch.flipT || 0) + dt) >= TUNE.faceFlip) { ch[field] = want; ch.flipT = 0; } } // ------------------------------------------------------------- ball boys ---- // The fetch boy is unchanged: sprint the width of the court, scoop the dead ball in passing. function fetchRound(b, dt) { const step = b.dir * 7.5 * dt; b.x += step; b.runT += dt; b.dist = (b.dist || 0) + Math.abs(step); if (!ball.collected && !ball.active) { const dz = ball.z - b.z; b.z += Math.sign(dz) * Math.min(Math.abs(dz), 3 * dt); // track the rolling ball a little if (Math.abs(b.x - ball.x) < 0.4 && Math.abs(dz) < 1.2) ball.collected = true; } if (Math.abs(b.x) > 13.5) G.boy = null; } // The mop boy actually does a JOB: walk to a stain, stand on it and push the mop until it's // gone, then move to the next one, then leave. He used to slide the width of the court in a // straight line at constant speed, which read as moonwalking past the mess rather than cleaning it. function mopRound(b, dt) { b.runT += dt; if (b.mopT > 0) { // ON a stain: no travel, but the push cycle keeps animating (dist drives frames) b.mopT -= dt; b.dist = (b.dist || 0) + TUNE.boyMopSpeed * 0.6 * dt; // the mop travels even when he doesn't if (b.mopT <= 0) b.jobs--; // that one's done — look for the next return; } // pick the next target: the biggest stain he can still reach, ignoring ones already scrubbed out if (b.tx === undefined && b.jobs > 0) { let best = null, bs = -1; for (const d of gore) { // faded, or up the far end where he can't work — EXCEPT in the match, where the far end is // exactly where every opponent dies: the mop beat holds an empty court for him, so the // gore-mode reach limit would leave him jobless two steps from the mess he was sent for if (d.a < 0.15 || (!G.match && d.z < COURT.netZ - 2)) continue; const score = d.a * d.r - Math.hypot(d.x - b.x, d.z - b.z) * 0.04; // big and close first if (score > bs) { bs = score; best = d; } } if (best) { b.tx = best.x; b.tz = best.z; } else b.jobs = 0; // nothing left to clean — go home } const working = b.tx !== undefined && b.jobs > 0; const gx = working ? b.tx : b.dir * 14, gz = working ? b.tz : b.z; // no target left: walk off the wing const dx = gx - b.x, dz = gz - b.z, d = Math.hypot(dx, dz); // during the match's mop beat he HURRIES between stains (the beat is his stage and it is 5 s // long; at walking pace he spent 4 of them getting into shot) — the push itself keeps its pace const spd = G.match && G.match.mopT > 0 ? TUNE.boyHurry : TUNE.boyMopSpeed; const step = Math.min(spd * dt, d); if (d > 0.001) { b.x += dx / d * step; b.z += dz / d * step; b.dist = (b.dist || 0) + step; } if (Math.abs(dx) > 0.05) b.dir = dx > 0 ? 1 : -1; // face the way he's walking if (working && d <= 0.12) { b.mopT = TUNE.boyMopDwell; b.tx = undefined; b.tz = undefined; } // arrived: get to work if (Math.abs(b.x) > 13.5) G.boy = null; // off the wing — round over } // ---------------------------------------------------------------- update ---- export function update(dt) { recordFrame(dt); if (G.moveTapT > 0) G.moveTapT -= dt; // the double tap's window (keyPressed) if (G.hitstop > 0) { G.hitstop -= dt; return; } // THE OTHER ENDING: a classic match played out. The world holds still — the man is standing, the // crowd is still clapping it down — and the only thing running is the clock the summary and its // restart tap are gated on. Same shape as the block below, without a corpse in it. if (G.done) { G.idleT += dt; G.done.t += dt; if (G.done.t >= EATEN_SUMMARY && !G.done.handed) { G.done.handed = true; menuDuck(false); } crowd.cheer = Math.max(0, crowd.cheer - dt); if (G.card && (G.card.t -= dt) <= 0) G.card = null; return; } if (G.mode === 'eaten') { // the match is over in the truest sense G.idleT += dt; const was = G.eatenT; G.eatenT += dt; if (G.eatenT < EATEN_BURST) eatenBleed(dt); // phase A: they are still tearing at you if (was < EATEN_BURST && G.eatenT >= EATEN_BURST) playerGore(); // phase B: you become the gore // phase C: the summary IS the end menu — hand the soundtrack back to the menu theme. Not at // killPlayer: the death itself already has the massacre call and the crowd's groan over it. if (was < EATEN_SUMMARY && G.eatenT >= EATEN_SUMMARY) { zombieDuck(true); menuDuck(false); } crowd.cheer = Math.max(0, crowd.cheer - dt); // the stand sits back down over the death too — // a cheer live at the kill used to clap forever if (G.netRipple && (G.netRipple.t -= dt) <= 0) G.netRipple = null; // settles rather than freezing if (G.shake && (G.shake.t -= dt) <= 0) G.shake = null; if (G.boomFlash > 0) G.boomFlash -= dt; for (let i = pops.length - 1; i >= 0; i--) if ((pops[i].t -= dt) <= 0) pops.splice(i, 1); // no DOUBLE KILL frozen over the feast updateZombies(dt); return; } if (G.shake && (G.shake.t -= dt) <= 0) G.shake = null; if (G.netRipple && (G.netRipple.t -= dt) <= 0) G.netRipple = null; // the tape settles if (G.zoom && (G.zoom.t += dt) >= G.zoom.T) G.zoom = null; // scripted punch-in (render applies it) if (G.bossBurst && (G.bossBurst.t -= dt) <= 0) G.bossBurst = null; if (G.boomFlash > 0) G.boomFlash -= dt; // A modal freezes the WORLD, not the clock: no zombie steps, no ball flight, no spawn timers, // so nobody gets eaten while reading a popup. idleT keeps running for the blink. if (G.modal) { G.idleT += dt; const m = G.modal; // the entrance is the ONE thing that animates behind the freeze if (m.in < 1) { if ((m.in = Math.min(1, m.in + dt / TUNE.modalIn)) >= 1) pock(0.4, 620); // it lands with a thud } // ...and a modal may carry its OWN clock (`hold`): it announces, holds the frozen court for a // couple of seconds and hands it back without being tapped. The boss walk-on is the case — an // announcement the player must be PREPARED by, where waiting for a tap would be the control // steal the popup policy forbids. Only ticks once it has landed; a tap still dismisses early. else if (m.hold > 0 && (m.hold -= dt) <= 0) dismissModal(); return; } // The flash is FROZEN behind a popup too, and it has to be: it used to tick above the return, so // any flash raised in the same beat as a modal spent its whole 1.1 s invisible behind the dim and // was gone before you had tapped. That is exactly what made a won duel look like it paid nothing // late in a run — the weapon modal is first-sight-only, so once you have seen a tin the label // flash IS the announcement, and the trainer's popup was eating it every time you were wounded. if (G.flash) { G.flash.t -= dt; if (G.flash.t <= 0) G.flash = null; } // A card can wait before it shows. The chapter card used to land on the same frame as the boss // died, so the biggest gore burst in the game happened underneath it and nobody ever saw it. // BELOW the modal return, like the flash: a card raised in the same beat as a popup used to // burn its whole life dimmed-out underneath it — now it holds and plays AFTER the dismissal. if (G.placeMark && (G.placeMark.t -= dt) <= 0) G.placeMark = null; // the placement ring fades if (G.card) { if (G.card.wait > 0) G.card.wait -= dt; else { G.card.t -= dt; if (G.card.t <= 0) G.card = null; } } for (let i = pops.length - 1; i >= 0; i--) if ((pops[i].t -= dt) <= 0) pops.splice(i, 1); // reaction pops decay crowd.cheer = Math.max(0, crowd.cheer - dt); G.idleT += dt; if (G.asideT > 0) G.asideT -= dt; // the asides' shared cooldown (see aside()) if (G.praiseT > 0) G.praiseT -= dt; // ...and the praise words' (see praise()) // the fired band unravels behind the ball. It also goes the moment the ball does — a shambler // eating the shot ends the band with the splat, which is a cut you want. if (G.trail && ((G.trail.t += dt) >= G.trail.T || !ball.active)) G.trail = null; // "Time." belongs to the changeover alone. It used to ride the generic 'over' expiry, so any // other beat that overwrote overT while the breather ran (a melee kill taking a game, say) // had the umpire calling time on a pause that was never a changeover. if (G.changeover > 0 && (G.changeover -= dt) <= 0) { G.changeover = false; voice('time', 0, 0.95); } const w = BOUNCE_IDLE.tempo; // idle in the serve stance and not touching anything: after a beat, show the pull gesture if (G.phase === 'pretoss' && !G.aim) G.idleHintT += dt; else G.idleHintT = 0; if (G.phase === 'pretoss' && Math.sin(G.idleT * w) * Math.sin((G.idleT - dt) * w) < 0) pock(0.12, 420); // ball bounce // the toss/smash pose belongs to the SHOT, not to the aim: while the slingshot is drawn (G.aim) // the server stays in the pretoss stance and only snaps into 'serve' when hitServe fires. if (G.phase === 'toss' && !G.aim) { const sv = score.server === 'P' ? P : O; sv.pose = 'serve'; sv.poseT = 0; } // --- player movement (near half only, generous margins) // 'rally' ONLY, deliberately. Letting the player walk during the between-point beats was tried // and vetoed on feel: you never walk freely during play, so suddenly being able to between points // reads as the game breaking, not as a fix. The frozen-changeover complaint is answered by // holding the DEAD instead (see updateZombies), and the stuck pose by the reset below. // ...and a live BURST is the exception, for the same reason the horde forces 'rally': it is an // auto-fire control mode that owns the finger for its whole window, and the beats that fire when // it clears the court (a point, a won game, the mop-up) park the phase in 'over'. Gating the // steer on the phase froze the player mid-burst the moment he was winning. The settled "no // walking between points" rule is untouched — nothing arms a burst except the player. if (G.phase === 'rally' || G.mgT > 0) { const px0 = P.x, pz0 = P.z; // MOVEMENT READS: the frame's real travel, measured after the clamps if (swingLog() && ball.active && ball.lastHit === 'O') { // closest approach, for never-swung passes const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8); if (!G.rallyNear || d < G.rallyNear.d) G.rallyNear = { d, y: ball.y, bz: ball.z, px: P.x, pz: P.z }; } // auto-fire steering is ABSOLUTE: the finger x IS the player x — zero lag, and clamped // to what the camera can see (portrait's world bounds are wider than its viewport) const mgMode = G.horde || G.mgT > 0; const xVis = xVisible(); let mgSteer = false, mgMoving = false; if (mgMode && touchPX() !== null) { mgSteer = true; const tx = Math.max(-xVis, Math.min(xVis, touchPX() * xVis)); if (Math.abs(tx - P.x) > 0.04) { P.facing = tx > P.x ? 1 : -1; mgMoving = true; } P.x = tx; } const slow = P.swingT >= 0 ? 0.35 : 1; const mx = mgSteer ? 0 : axisX(), mz = mgSteer ? 0 : axisZ(); P.x += mx * TUNE.playerSpeed * slow * dt; P.z += mz * TUNE.playerSpeed * slow * dt; let assist = 0; // THE MATCH BRINGS ITS OWN LEGS, on every device. autoAssist keys off pointer:coarse, so the // desktop Browser pane (pointer: fine) had NO auto-run at all — the flight recorder showed // Oliver swinging in every lost point while his x never moved: a statue with a racket. The // match is a tap-timing game; footwork is the game's job. Held keys still override (mx/mz). const aK = assistK(); if (!mx && !mz && aK > 0 && ball.active && !G.horde && !(G.mgT > 0)) { // auto-fire: you aim by hand, no assist // splatter: anything travelling back toward you is yours to run down — your own rebound // included. The assist puts you there; the volley timing stays honest. const chasing = ball.lastHit === 'O' || (G.mode === 'splatter' && !G.interlude && ball.vz > 0); // no `slow` here: tapping must not freeze your legs, or anticipating a shot loses it // ...and lobbed at the net he backpedals, exactly as slowly as the CPU does (TUNE.lobTurn) // HIS OWN WAY IN AND OUT runs at full pace (the attack's follow-in, the lob's retreat), and while // his attack flies he is RUSHING: the reply is planned as a net man's from wherever he has got to // committed to the net he is a net man for every ball until he reaches it (a double tap behind his serve is serve and volley) if (P.post === 'base' && Math.abs(P.z - COURT.netZ) > TUNE.netFrom + 0.5) P.post = null; // back: the old rules again (a short ball still pulls him in) P.rushing = P.post ? P.post === 'net' : ball.lastHit === 'P' ? ball.approach === 'P' : P.rushing; const ownRun = !chasing && rallyFeel() && (P.post || ball.lastHit === 'P' && (ball.approach === 'P' || ball.retreat === 'P')); let rate = TUNE.playerSpeed * aK * dt * (chasing || ownRun ? 1 : 0.55) * (chasing && ball.rx && ball.rx.lob ? TUNE.lobTurn : 1); // ON HIS HEELS: a put-away of his own sitter is read late (step 4 (b)). Without this the auto-run // started on the strike's frame with a perfect read and covered the whole court — no put-away ever // won a point, and the CPU still had no way to take one const key = ball.lastHit + (chasing ? 'c' : 'r'); if (!chasing) P.chase = null; else if (!P.chase || P.goalKey !== key) P.chase = { x: P.x, z: P.z, t: 0, lost: 0, plan: null }; // where this chase began if (chasing && ball.heels > 0) { ball.heels -= dt; rate = 0; if (P.chase) P.chase.lost += dt; } else if (P.chase) P.chase.t += dt; // the time he has had to run, for intercept's reach // Run to the ball — then recover. WHERE you recover to is mode-specific and each rule exists // for a reason: // GORE SPLATTER recovers in DEPTH ONLY, holding your lane — recovering to HOME.P's x undid // the sidestep (measured: a step to 1.94 m dragged back to 0.77 m over one ball's flight), // which is the "welded in place" complaint the sidestep answers, arriving slowly. // TENNIS and THE MATCH recover to CENTRE — that is the real sport's footwork, and in a // rally there is no artillery lane to protect. The match inherited the gore rule by being // G.mode 'splatter', and the cost was exactly Oliver's "every second ball is basically // unreachable": one wide return pulled you off centre and you STAYED there, so the next // ball to the opposite corner was lost before it was struck. Not a strong CPU — a player // who never walked back. // ...and the plan is LATCHED for the chase: the flight is fixed the moment the ball is struck, so the // point chosen first stays the answer while his legs still make it in time (net of any time he stood: // a split-step, a put-away's heels). Re-asked every frame, every margin in intercept — a bounce that // peaks on a height floor, a point on the back boundary — flipped the plan by metres as the 10 ms // sampling grid slid against the frame: the shake Oliver saw. Hysteresis there only moved the margin. const c = P.chase, spdP = TUNE.playerSpeed * aK; const latched = c && c.plan && c.plan.ti !== undefined && Math.hypot(c.plan.x - c.x, c.plan.z - c.z) / spdP <= c.plan.ti - (c.lost - c.plan.lost0) + 0.02; const raw = chasing ? (latched ? c.plan : (c ? (c.plan = { ...intercept('P'), lost0: c.lost }) : intercept('P'))) : G.mode === 'splatter' && !G.match ? { x: P.x, z: HOME.P.z } : rallyFeel() ? rallyHome(P) : HOME.P; // THE RUN, SMOOTHED (2026-10-14, Oliver: "wenn der Autoplay den Ball versucht zu bekommen, zittert der // Player schnell hin und her"). intercept asks every frame for the EARLIEST point he reaches in time, // and his own step moves that point: the target jumped ~10 cm a frame (p90 0.3 m) and flipped across // him, start/stop at the deadzone's edge, dozens of reversals a minute. It now EASES toward each plan // (runSmooth); a new shot or a re-plan further than runSnap (the lob read) is still taken at once. const jump = P.goal ? Math.hypot(raw.x - P.goal.x, raw.z - P.goal.z) : Infinity; if (!P.goal || P.goalKey !== key || jump > TUNE.runSnap) P.goal = { x: raw.x, z: raw.z }; else { const k = Math.min(1, dt * TUNE.runSmooth); P.goal = { x: P.goal.x + (raw.x - P.goal.x) * k, z: P.goal.z + (raw.z - P.goal.z) * k }; } P.goalKey = key; const tgt = P.goal; P.tgt = tgt; // the timed gold ring (ringCue) predicts the contact from where this run ends const dx = tgt.x - P.x, dz = tgt.z - P.z, d = Math.hypot(dx, dz); // deadzone WITH HYSTERESIS: start at 0.35, and once running, carry on to runStop — a target hovering // at the edge no longer toggles him between a step and a stand every other frame P.running = d > (P.running ? TUNE.runStop : 0.35); if (P.running) { // deadzone: stand ready near the spot instead of dithering over it P.x += dx / d * Math.min(rate, d); P.z += dz / d * Math.min(rate, d); assist = dx; } } // Stay ON SCREEN — always, not just in auto-fire (x = -6.5 projects to screen -61 of 270). const xLim = xLimit(); P.x = Math.max(-xLim, Math.min(xLim, P.x)); P.z = Math.max(P_BOUNDS.zMin, Math.min(P_BOUNDS.zMax, P.z)); // mgMoving MUST be in here. Finger-steering zeroes mx/mz (they are the keyboard axes), so a // player sliding left and right under auto-fire counted as standing still and never ran — // "when he's moving left and right the run animation is not applied". const moving = mgMoving || mx || mz || assist !== 0 || G.dodgeT > 0; P.runT += moving ? dt : 0; // Auto-fire used to be excluded here, which left the pose stuck on 'idle' — and render then // faked a run cycle off G.idleT so he was not a statue. That fake is what read as "this hectic // run animation" whether he was moving or not. The pose follows MOVEMENT in every mode now, so // auto-fire uses the ordinary run animation and standing still is standing still. if (P.pose !== 'swing' && P.pose !== 'serve') P.pose = moving ? 'run' : 'idle'; if (mx) { P.facing = mx > 0 ? 1 : -1; P.flipT = 0; } // manual: instant, and it resets the earn clock else earnFlip(P, 'facing', Math.abs(assist) > 0.25 ? (assist > 0 ? 1 : -1) : 0, dt); // MOVEMENT READS: the player's own travel this frame, measured AFTER the clamps so a body // pinned against the bound reads as standing (drawChar's speed ghosts read vx/vz). A real // sprint — the auto-run chase, the horde steer — kicks the same per-court dust the bodies do. P.vx = (P.x - px0) / dt; P.vz = (P.z - pz0) / dt; const pSpd = Math.hypot(P.vx, P.vz); if (TUNE.trailMin && pSpd > TUNE.trailMin && (P.dustAcc = (P.dustAcc || 0) + pSpd * dt) > TUNE.runDustEvery) { P.dustAcc = 0; dustPuff(P.x, P.z, 2, 0.6, skillDust()); } } // ...and out of 'rally' he STANDS. 'run' is the one pose nothing else expires (expirePose only // handles swing/serve), and P.runT stops advancing outside the block, so a beat that began // mid-stride left him welded to a single run frame — the "stuck mid-animation" report. // vx/vz die with the mover too, or the speed ghosts would trail a body that stopped dead. else { P.vx = 0; P.vz = 0; if (P.pose === 'run' && G.dodgeT <= 0) { P.pose = 'idle'; P.poseT = 0; } } // THE GOLD RING, STEADY (Oliver, 2026-10-14: "der Treffer-/Zielkreis zittert jetzt manchmal"). // ringCue re-solves every frame against where the auto-run is taking him, and a target that shifts // a few centimetres moved the predicted moment by a frame or two — the ring's size twitched and its // gold blinked. The drawn cue COUNTS DOWN on its own clock and only eases toward each fresh solve // (TUNE.ringSmooth), and a frame with no solve holds it instead of dropping it. if (G.phase === 'rally' && ball.active && ball.lastHit === 'O' && (rallyFeel() || G.mode === 'tennis')) { const c = ringCue(); if (c && !G.ring) G.ring = { ...c }; else if (c) { const e = G.ring.eta - dt; G.ring = { ...c, eta: e + (c.eta - e) * Math.min(1, dt * TUNE.ringSmooth) }; } else if (G.ring && (G.ring.hold = (G.ring.hold || 0) + dt) < 0.12) G.ring.eta -= dt; else G.ring = null; if (G.ring) G.ring.gold = Math.abs(G.ring.eta - G.ring.sweet) < G.ring.band; } else G.ring = null; // The sidestep runs in EVERY phase — that is the entire point of it, since a boss fight is spent // in 'pretoss' where the block above never runs. Same clamp as the walk, so it cannot carry him // off the edge of the screen either. if (G.dodgeCd > 0) G.dodgeCd -= dt; if (G.dodgeI > 0) G.dodgeI -= dt; if (G.dodgeT > 0) { G.dodgeT -= dt; const xLim = xLimit(); P.x = Math.max(-xLim, Math.min(xLim, P.x + G.dodgeDir * (TUNE.dodgeDist / TUNE.dodgeT) * dt)); P.runT += dt; // the legs move, so the run cycle advances if (G.dodgeT <= 0) G.dodgeDir = 0; } // splatter: you START at the baseline but you're free to advance up-court (bounded at the net by // P_BOUNDS) — hunt the shamblers down, especially when mopping up a won game. Auto-assist still // recovers you toward the baseline between shots on touch; the duel is plain tennis as ever. if (P.swingT >= 0) { P.swingT += dt; if (swingLog() && ball.active && ball.lastHit === 'O') { // sample the approach for the miss log (t = what trySwing tests this frame) const volley = ball.bounces === 0; (G.swingSnaps ||= []).push({ t: P.swingT, d: Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8), y: ball.y, bz: ball.z, bounces: ball.bounces, lb: letBounce(), // the reach trySwing really tests — the match's longer racket included, or a near miss reads wide r: TUNE.reach * (volley ? VOLLEY.reach : 1) * (rallyFeel() ? TUNE.matchReach : 1), w: TUNE.swingWindow * (volley ? VOLLEY.window : 1) * winLevel() }); } trySwing(P, false); // a contact resets swingT to -1 inside trySwing // volley-length window while a hostile shot is in the air too, or the swing clock expires at // 0.24 s and closes TUNE.parryWindow (0.34 s) before it has had its chance const win = TUNE.swingWindow * ((shots.length || (ball.active && ball.bounces === 0)) ? VOLLEY.window : 1) * winLevel(); // the clock must outlive the widened window or the level's gift is dead time if (P.swingT > TUNE.swingWindup + Math.max(win, TUNE.swingWindow * winLevel() + swingTail(false)) + 0.02) { P.swingT = -1; if (G.phase === 'rally') swish(); logWhiff(); } // log even if the point ended mid-swing } expirePose(P, dt); // --- zombies + transition timeline if (G.mode !== 'tennis') updateZombies(dt); else updateParts(dt); updateBench(dt); // the sprite bench's pacing body — a no-op unless the back room opened it updateDrill(dt); // the coaching drill's round clock. It MOVES its bodies (they carry v = 0, so // updateZombies' shamble skips them) and updateZombies still runs the contact // and ball-hit tests against wherever this put them — that split is the design. // tennis has no zombies but its bounces still kick up dust that must settle tickTestMode(dt); // keeps the sandbox stocked and rolls it a barrel now and then if (G.meleeT > 0) G.meleeT -= dt; if (G.rackGlow && (G.rackGlow.t -= dt) <= 0) G.rackGlow = null; // the fresh-pickup pulse fades out watchdog(dt); if (G.net) netTick(dt); // netplay: the 10 Hz position stream out, the opponent's body in // !G.horde: a horde mows the court down to an EMPTY FRAME between spawns, and a clear latched // before the wave began (a game won during the set card) then advanced the beat mid-horde — // which is how a sudden-death duel once started INSIDE a horde. The clear waits for endHorde. if (G.clearing && !G.horde && !zombies.some(blocksClear)) proceedAfterClear(); // the won-game wave is mopped up — advance if (G.mode === 'transition') transition(dt); // --- interlude trigger: the court is clear, an opponent holds the far court, you're about // to serve — the umpire recognizes a legal match and restores order. One point. Sudden death. // The schedule is a FLOOR, not a ceiling: this lucky-clear trigger stays, and a bonus duel is // pure upside for weapon supply. if (G.mode === 'splatter' && !G.interlude && !G.horde && !G.changeover && !(G.mgT > 0) && !G.bossFight && G.phase === 'pretoss' && zombies.length === 1 && zombies[0].variant === 'r' && !zombies[0].dueled && !G.story) // the story has no duel at all startDuel(); if (G.interlude && G.interlude.introT > 0) G.interlude.introT -= dt; // the duel is played under tennis lights: lerp the palette home while it runs, back after G.duelLerp = Math.max(0, Math.min(1, G.duelLerp + (G.interlude ? dt : -dt) / 0.8)); // --- AI (the opponent only exists in tennis mode) // THE MATCH SERVES TOO. This was gated on `G.mode === 'tennis'` and the match runs in splatter // under a permanent interlude, so the corpse could only ever RETURN — return timing, half the // sport, was never asked of the player. setupPoint already places a non-player server behind its // own baseline and arms aiServeT; this is the one condition that was keeping it switched off. // The intro beat holds it: a body still walking on must not open with a serve. const oServes = (G.mode === 'tennis' || G.match) && score.server === 'O' && !(G.interlude && G.interlude.introT > 0) && !G.net; // netplay: the far side serves on the OTHER phone — the serve arrives as a shot event if (oServes && G.phase === 'pretoss') { G.aiServeT -= dt; if (G.aiServeT <= 0) tossBall(O); } else if (oServes && G.phase === 'toss') { if (ball.vy < 0 && ball.y < serveApexY() - 0.1) { G.wasServe = true; hitServe(O, Math.min(1, (0.6 + Math.random() * 0.35) * (G.diff ? G.diff.serve : 1))); // ...and the body that just served holds the strike, so the swing reads as its own (beats) if (G.match) { const rz = zombies.find(z => z.variant === 'r'); if (rz) rz.serveHold = 0.4; } } } if (G.mode === 'tennis' && G.phase === 'rally' && !G.net) { // netplay: O is a real person on // another phone — netTick drives the body G.aiReactT += dt; const incoming = ball.active && ball.lastHit === 'P' && G.aiReactT >= (G.diff ? G.diff.reaction : TUNE.aiReaction); // THE INTERCEPT GETS A DEADBAND (Oliver: "switching between a left and right movement // increasingly faster the nearer the ball comes"). intercept() re-solves every frame, and as // 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') : 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; const spd = TUNE.aiSpeed * (G.diff ? G.diff.speed : 1) * (incoming ? 1 : (G.diff ? G.diff.recover : 0.5)); // slow recovery: wrong-footing wins points const dx = tgt.x - O.x, dz = tgt.z - O.z, d = Math.hypot(dx, dz); if (d > 0.1) { O.x += dx / d * Math.min(spd * dt, d); O.z += dz / d * Math.min(spd * dt, d); O.pose = O.pose === 'swing' ? 'swing' : 'run'; O.runT += dt; earnFlip(O, 'facing', Math.abs(dx) > 0.25 ? (dx > 0 ? 1 : -1) : 0, dt); } else if (O.pose === 'run') O.pose = 'idle'; G.aiSwingCool -= 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 && !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). // CLASSIC TENNIS turns the band OFF and flubs less: the band exists to get a new player // PAST the opener, and a mode whose whole promise is a real match cannot be won by an // opponent that falls apart the moment he goes ahead. const flub = G.classic ? (G.diff ? G.diff.flub : TUNE.classicFlub) : TUNE.aiFlub + score.games[1] * TUNE.aiFlubLoss; if (Math.random() < flub) G.aiSwingCool = 0.5; // whiff: hesitates and misses else { O.swingT = TUNE.swingWindup + TUNE.swingWindow / 2; trySwing(O, true, G.classic ? (G.diff ? G.diff.quality : TUNE.classicQualityMax) : TUNE.aiQualityMax); O.swingT = -1; G.aiSwingCool = ball.lastHit === 'O' ? 0.4 : 0.12; } } } } expirePose(O, dt); // --- racket zombies rally from the far court (splatter only, between hordes) if (G.mode === 'splatter' && !G.horde) // ...and not while the body is on the ground: the fall owns the sprite AND the pose for its // whole run, and racketAI would put a collapsing corpse back on its feet to chase a stray ball. if (!G.bench && !G.matchFall && !G.net) for (const rz of zombies) if (rz.variant === "r") racketAI(rz, dt); // the bench drives its own body; netplay's body is the other phone (netTick) // --- ball physics if (ball.active) { // fire trail. One brief ember a frame read as a spark, not as a ball that is ON FIRE — this is // the weapon whose whole pitch is "kills on any touch and sets the others alight", so it now // sheds THREE embers a frame with real spread, they live long enough to hang as a tail behind // the arc, and every third leaves a puff of smoke drifting off the top of it. if (ball.flame) { for (let k = 0; k < 3; k++) parts.push({ x: ball.x + (Math.random() * 2 - 1) * 0.14, y: ball.y + (Math.random() * 2 - 1) * 0.14, z: ball.z + (Math.random() * 2 - 1) * 0.14, t: 0.34 + Math.random() * 0.26, fire: true, vx: (Math.random() * 2 - 1) * 0.9, vy: 0.9 + Math.random() * 0.7, vz: (Math.random() * 2 - 1) * 0.9 }); if (Math.random() < 0.34) parts.push({ x: ball.x, y: ball.y + 0.15, z: ball.z, vx: (Math.random() * 2 - 1) * 0.3, vy: 0.7, vz: (Math.random() * 2 - 1) * 0.3, t: 0.5 + Math.random() * 0.4, smoke: true }); } ball.vy -= (ball.g || TUNE.gravity) * dt; const oldZ = ball.z; ball.x += ball.vx * dt; ball.y += ball.vy * dt; ball.z += ball.vz * dt; // net: plane at netZ, only during rally flight if (G.phase === 'rally' && (oldZ - COURT.netZ) * (ball.z - COURT.netZ) < 0 && Math.abs(ball.x) < COURT.netW / 2) { const tCross = (COURT.netZ - oldZ) / (ball.z - oldZ); const yAt = ball.y - ball.vy * dt * (1 - tCross); // approx height at crossing if (yAt < COURT.netH) { ball.z = COURT.netZ + (oldZ < COURT.netZ ? -0.05 : 0.05); ball.vz *= -0.08; ball.vx *= 0.3; ball.vy = Math.min(ball.vy, 0); pock(0.4, 300); G.netRipple = { x: ball.x, t: 0.4 }; // the tape carries the hit: a damped wobble where it clipped } } // THE FALL ANTICIPATES THE PASS (Oliver: "the losing point could still be more anticipated // timing-wise"). Once the player's winner is past the net its landing is closed-form knowable // (predictLanding is exact), so a ball that will bounce in and is provably beyond the body's // reach resolves MID-FLIGHT — the collapse starts while the ball is still in the air. The // bounce-1 hook in onBounce stays as the fallback for anything too close to call up here. // !G.net for the same reason its bounce-1 sibling carries it, and MISSING IT WAS THE DEATHMATCH // DESYNC ("both players stand on the same side and nobody can serve", cellular): this predicts // the CPU's legs, and a human on another phone is not predictable. Worse, it made the STRIKER a // judge of its own outbound flight — resolveInterlude sends the verdict — so on a slow link both // phones scored the same rally, the two scoreboards drifted a point apart, and setupPoint then // placed each P on its own deuce/ad side while each waited for the other to serve. The one rule // is that the receiving phone judges; this was the one place still breaking it. if (G.match && !G.net && !G.match.ending && !G.match.decided && G.phase === 'rally' && ball.active && ball.lastHit === 'P' && ball.vz < 0 && ball.z < COURT.netZ - 0.3 && matchFallWillPlay()) { const arz = zombies.find(z => z.variant === 'r'); const pred = arz && predictLanding(); if (pred && inCourt(pred.x, pred.z) && matchOutOfReachFlight(arz, pred)) { G.match.decided = true; // same latch discipline as the bounce-1 hook below G.matchEarly = true; resolveInterlude('P', 'unreachable_in_flight'); G.matchEarly = false; } } if (ball.y <= 0 && ball.vy < 0) { // EXACT TOUCHDOWN, not the frame after it. The integrated position lands up to one full // step PAST the true contact — at serve pace that is ~0.75 m of travel in 1/60 s, so a // serve aimed half a metre inside the service line was routinely JUDGED long. Oliver saw it // as "classic tennis gives double fault very often" (the match's line call only checks the // whole court, which is why zombie tennis seemed immune). Rewind the step to y = 0 before // the bounce position is read by anything — the line call, the bounce dust, the mark. const yOld = ball.y - ball.vy * dt; // where this step began (vy already g-updated) if (yOld > 0) { const back = dt * (1 - yOld / (yOld - ball.y)); // time spent below the ground this step ball.x -= ball.vx * back; ball.z -= ball.vz * back; } ball.y = 0; ball.vy = -ball.vy * bY(); ball.vx *= bXZ(); ball.vz *= bXZ(); ball.g = gAfter(ball.g); // the dip ends at the bounce: a topspin ball kicks UP now (gAfter) if (Math.abs(ball.vy) > 1.0) dustPuff(ball.x, ball.z, 3, 0.7); // a real bounce kicks up the court onBounce(); if (ball.active && Math.abs(ball.vy) < 0.8) { // rolled out — dead ball ball.active = false; if (ball.scored) { /* already called: this IS the natural halt it was left rolling for */ } else if (G.mode === 'splatter' && G.interlude) { // a roller nobody returned: point to the hitter if (ball.lastHit === 'X') spentBall(); // spent on the boss's body — no point, re-serve the duel else resolveInterlude(ball.lastHit, 'dead_roller'); } else if (G.mode === 'splatter') { // hand the server a fresh one, fast — they're coming spentBall(); sendBoy(); if (Math.random() < 0.12) voice('new_balls', 0.3); } else if (ball.bounces === 1) pointTo(ball.lastHit, null); } } // a racket zombie's return that gets past you (crosses your baseline) is a point for THEM. // IN THE MATCH "past you" means past your REACH (2026-10-14, Oliver's v366 log): since step 1 a // baseliner waits up to rallyBehind BEHIND his line, so the old line — 0.3 m behind the baseline — // called the point while the ball was still on its way to his racket. Every deep topspin ball the // auto-run met deep was a "passed you" with no swing. The duel keeps the old line. const passZ = G.match ? Math.max(COURT.L + 0.3, P.z + TUNE.reach * TUNE.matchReach + 0.3) : COURT.L + 0.3; if (G.mode === 'splatter' && ball.active && !ball.scored && ball.lastHit === 'O' && ball.z > passZ) { ball.active = false; if (G.interlude) resolveInterlude('O', 'passed_player'); // sudden death: its passing shot ends the duel else { if (!G.horde && !G.clearing) splatPoint('O'); spentBall(); } // clearing = mop-up, no scoring either way } // the far backstop: a LIVE ball doesn't leave the field, it rebounds off the fence and comes // back down-court — still lethal on the way home, and yours to volley. Spent balls just die. if (G.mode === 'splatter' && !G.interlude && ball.active && ball.lastHit === 'P' && ball.z < TUNE.backstopZ && ball.vz < -3) { // only a ball with real pace left comes back; a roller just stops ball.z = TUNE.backstopZ; ball.vz *= -TUNE.backstopKeep; ball.vx *= 0.7; ball.vy = Math.max(ball.vy, TUNE.backstopPop); // the fence kicks it up, so it carries back through the crowd pock(0.5, 200); // chain-link thud } // splatter: retire spent or hopeless balls fast so the next serve comes right up. // z-bounds gate on travel direction — a serve born behind the baseline (P.z up to 26) // heads to the far court (vz<0), so it must not read as "out the back" on frame one. // (during the interlude only spent balls retire early — flying balls must reach their // bounce so the line call can happen; the umpire is watching again) if (G.mode === 'splatter' && ball.active && ((ball.lastHit === 'X' && ball.bounces >= 1) || (!G.interlude && ((ball.z < -2 && ball.vz < 0) || (ball.z > 25.5 && ball.vz > 0) || Math.abs(ball.x) > 7.5)))) { spentBall(); } if (G.phase === 'toss' && ball.y <= 1.4 && ball.vy < 0 && !G.aim) { // uncaught toss → retoss (a held slingshot aim keeps the toss up) ball.active = false; G.phase = 'pretoss'; // an uncaught toss is NOT a swing. The 'serve' pose used to survive into pretoss, and // drawChar's pick (phase !== 'toss' → smash) flashed the SMASH frame at a ball the player // never hit — Oliver: "the smash frame is always played even if I did not tap a second // time". Back to idle, where the bounce stance picks him up. const sv = score.server === 'P' ? P : O; if (sv.pose === 'serve') { sv.pose = 'idle'; sv.poseT = 0; } if (score.server === 'O') G.aiServeT = 0.8; } } // ball boy: sprint across, scoop the ball in passing, despawn off the far side const boy = G.boy; if (boy) boy.mop ? mopRound(boy, dt) : fetchRound(boy, dt); // The announced breather runs on its OWN clock, not on the 'over' beat it also sets: any beat that // moves the phase underneath it (a duel taking the calm court, say) would otherwise strand the // latch forever — the mgT lock, exactly. If the court is taken when it expires, the latch is // re-armed and the set gets its horde later rather than losing it. Above the 'over' block so the // wave lands before that block can put the player back in the serve stance for one frame. if (G.hordeWarn > 0 && (G.hordeWarn -= dt) <= 0) { G.hordeWarn = 0; if (!startHorde()) G.pendingHorde = true; } // ...and the match's FALL runs on its own clock for exactly the same reason, above the 'over' // block so it can hand the point back before that block puts the player in a serve stance. tickMatchFall(dt); tickStory(dt); if (G.phase === 'over') { G.overT -= dt; if (G.overT <= 0) { // A boss (or a duel) OUTRANKS the horde, and pendingHorde is a latch: it is armed when the set // banks and cashed by whichever 'over' beat expires next — which, at game 30, is the chapter // card, because 30 is both a set and a boss. The wave then poured onto the boss stage. Leave the // latch alone and let the beat AFTER the fight cash it: the set still gets its horde, later. if (G.pendingHorde && !G.bossFight && !G.interlude) { // the set card's pause is over — but the horde is a different CONTROL MODE G.pendingHorde = false; // (racket auto-fires, the finger steers you absolutely), if (!G.seen.horde) { // so its FIRST arrival gets a real block... G.seen.horde = true; showModal({ title: 'THEY KEEP COMING', color: UI.hot, body: ['THE RACKET FIRES ON ITS OWN', 'SLIDE TO AIM · HOLD THE BASELINE'], onDismiss: warnHorde }); } else warnHorde(); // ...and every later set starts on the flash alone. Re-showing the // same popup at the hottest moment of every set was one of the // "popups get me killed" reports — you know the rules by set two. return; } // auto-fire modes must NEVER re-enter the serve loop: an 'over' beat expiring into // setupPoint() -> pretoss left the player locked in the serve posture while the gun // fired (no movement outside 'rally', no serving during auto-fire). Back to rally. if (G.horde || G.mgT > 0) { G.phase = 'rally'; ball.active = false; P.pose = 'idle'; P.poseT = 0; P.swingT = -1; } else setupPoint(); } } }