Files
splatter-tennis/src/gameplay.js
T
wuwiandClaude Opus 5 4f4534e272 The lob is the flick you already have, and a game stops being a death
Three from Oliver.

THE LOB. He picked option (c) from the written comparison — "no additional
gesture to learn" — and corrected the premise: the zombie does come to the
net (racketAI chases to the far service line, the classic CPU to z 9.5). So
the forward flick keeps its one meaning, "put it deep behind him", and the
game picks the trajectory that actually does that: a smash while he is back,
over his head once he is in. oppAtNet() is the whole switch, and it reads the
BODY, not O — in the match O is the invisible tennis body and only moves
between points, so it would have answered "on his mark" all rally.

lobArm 7.0 m is measured, not taste: a lob peaking at lobApex passes a man 7 m
from the net at almost exactly CONTACT.maxY, so the shot arms exactly as far
out as it can still clear him. The pace is derived from the height, so the same
arc comes off the baseline and off mid-court, and SHOT_DEPTH.lob is deeper than
deep — at the ordinary deep target the ball passed 2.05 m over him at the edge
of the band, which is inside his reach.

PACE, SEEN FROM THE STRIKER. "the opponent sees the ball really fast - the
player barely sees the speed increase." Measured: the ball is the same ball on
both phones, the camera is not — it compresses your own ball into the distance
and expands theirs as it arrives. Pace untouched; three channels added on the
striker's side: the ghost streak now reads pace (3-8 ghosts, was 3 at every
speed), the crack is pitched by it, and a fast ball kicks the camera. The
serve's own ladder is 49.2 plain / 50.8-52.4 hard, because serveArc's loft
margin takes 10% of the +9 back — widening that is his call, and PROJECT.md
records the knob and the risk.

THE DEATH MATCH. "each player has 8 blood points but the player explodes for
the winner already at 0:40." Two clocks: a game is four points, a life is
eight. A won game is only a game now (flash, serve, heal); the burst moved to
the death itself, on the dead message, where both phones agree. The summary
waits 1.4 s for the gore, because update() freezes the world on G.done and
would have hung a still frame of an explosion behind the trophy screen. And
bleedO tracks THEIR blood off the pos stream instead of our points-this-game,
which reset every game and ignored the heal.

4 new tests, 113 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 12:58:48 +02:00

2197 lines
144 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
import { TUNE, UI, bossArt, 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, CUP_ROUNDS } 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 { 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 } 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.
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
};
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.
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) {
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: DUST_COL[court.id] || '#98948a' });
}
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);
// WHERE THE MAN ACROSS THE NET ACTUALLY IS — and it is not always O. In the duel and the match the
// opponent is the BODY: O is the invisible tennis body the serve machinery is driven off, and it
// only moves between points, so asking O where the opponent is would answer "on his mark" all
// rally. In classic and in netplay (both kinds) O is the opponent, drawn and driven.
const farBody = () => (!G.net && zombies.find(z => z.variant === 'r')) || O;
// HAS HE COME IN? The one question the lob is armed on — see TUNE.lobArm for why 7 m and not taste.
// Exported for the suite and for anything that ever wants to show the player that the shot is live.
export const oppAtNet = () => COURT.netZ - farBody().z < TUNE.lobArm;
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, lob = false;
let s = TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed;
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);
}
// THE LOB IS THE SAME FLICK, READ AGAINST WHERE HE IS STANDING (Oliver 2026-08-22, "lupfen").
// Forward flick = "put it deep behind him", and when he has come to the net the trajectory that
// does that is over his head, not through him. No new gesture, nothing to learn, and it cannot
// be mis-fired — with him on his baseline the same flick is the smash it has always been.
lob = ch.side === 'P' && aim.az < 0 && oppAtNet();
const tzBase = lob ? SHOT_DEPTH.lob
: aim.az < 0 ? SHOT_DEPTH.deep : aim.az > 0 ? SHOT_DEPTH.short : SHOT_DEPTH.mid;
tz = (toFar ? tzBase : COURT.L - tzBase) + err() * 0.8;
// 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 && !lob) s *= 1 + TUNE.smashBoost * quality;
// PACE DERIVED FROM THE HEIGHT, not chosen: for the lob to peak at lobApex it must leave at
// vy = sqrt(2g(apex - y)), which fixes the hang time, and the hang time over the ground still
// to cover fixes the speed. Same arc from the baseline and from mid-court — a fixed speed
// would be a lob from one and a flat drive from the other. Timing still prices it: `tz` above
// already carries the same scatter every aimed ball does.
if (lob) {
const vy0 = Math.sqrt(2 * TUNE.gravity * Math.max(0.5, TUNE.lobApex - 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;
}
aimedLane = true;
}
tx = Math.max(-5, Math.min(5, tx));
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 + (G.match.opp - 1) * 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 + (G.match.opp - 1) * 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
// ...and a lob carries NO topspin: the dive multiplier is what makes a hard flat ball land in, and
// on a shot whose whole point is hang time it would just pull the arc down into the net.
const g = lob ? TUNE.gravity : TUNE.gravity * (1 + quality * TUNE.spinFactor);
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;
ball.lastHit = ch.side; ball.bounces = 0;
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
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) popText(ball.x, ball.y, ball.z, w);
}
// ...and the lob NAMES ITSELF. (c) buys "no gesture to learn" by letting context change what the
// flick means, and the price of that is that nothing on screen says which shot you just asked
// for. One word at the contact, in the praise words' own slot, is what pays it back.
if (lob) popText(ball.x, ball.y + 0.3, ball.z, 'LOB');
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;
// 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;
// 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.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
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]++;
if (score.pts[i] >= 4 && score.pts[i] - score.pts[j] >= 2) {
score.games[i]++; score.pts = [0, 0];
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) { // their ball died on your side untouched — and no whiff already explains it
const n = G.rallyNear;
logSwing({ result: 'PASSED', reason: `never swung in range — ball got ${r2(n.d)}m away at y=${r2(n.y)}m`,
dMin: r2(n.d), y: r2(n.y), ballZ: r2(n.bz), px: r2(n.px), pz: r2(n.pz) });
G.passStreak++; // two of these in a row and the ghost hand shows the tap (render.drawHitHint)
}
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.
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 (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
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 = [...TINS, ...Object.keys(BARREL_WEAPONS).filter(w => G.runGames >= BARREL_WEAPONS[w].from)];
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)
grantWeapon(id);
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: '×' + WEAPONS[id].tin,
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() {
netClose(); // netplay: resetState wipes G.net below; the socket must not outlive the run it carried
resetZombies(); resetInput(); resetState(); setupPoint();
beginRun(); // ...and the stats bag, which is the only state that does not live on G
}
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
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)); }
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.
const rzChaseSpeed = rz => TUNE.racketSpeed
* (G.match ? (TUNE.matchSpeedBase + (G.match.opp - 1) * TUNE.matchSpeedPer) * ((rz.foe && rz.foe.speed) || 1) : 1)
* (G.wasServe && ball.lastHit === 'P' ? TUNE.aiReturnDash : 1);
// Can the match opponent still reach this ball before its second bounce? Closed-form flight from
// the POST-BOUNCE state (velocities already damped when the line call runs) against the body's own
// 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 * TUNE.bounceY) / g);
const vx2 = ball.vx * TUNE.bounceXZ, vz2 = ball.vz * TUNE.bounceXZ;
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) { popText(ball.x, 0.3, ball.z, ball.praise); 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)); 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');
G.matchEarly = false;
}
} else if (ball.bounces === 2) { endFlight(); resolveInterlude(ball.lastHit); }
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);
export const assistK = () => rallyFeel() ? Math.max(TUNE.autoAssist, TUNE.matchAssist) : TUNE.autoAssist;
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;
const g = ball.g || TUNE.gravity, dt = 0.01;
const inZone = side === 'O' ? zz => zz <= ZONE_DEPTH : zz => zz >= COURT.L - ZONE_DEPTH;
const home = HOME[side];
let fallback = null, deepest = null, anyHit = null;
for (let i = 0; i < 300; i++) {
vy -= g * dt; x += vx * dt; y += vy * dt; z += vz * dt;
if (y <= 0 && vy < 0) { y = 0; vy = -vy * TUNE.bounceY; vx *= TUNE.bounceXZ; vz *= TUNE.bounceXZ; }
// 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) };
if (Math.hypot(pt.x - ch.x, pt.z - ch.z) / spd <= i * dt) 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) };
const reachable = Math.hypot(pt.x - ch.x, pt.z - ch.z) / spd <= i * dt;
if (y > 0.7 && reachable) return pt; // waist-high AND arrivable in time
if (reachable && !fallback) fallback = pt; // low but arrivable
deepest = pt; // last resort: run at the deepest hittable point
}
}
return fallback || deepest || anyHit || home; // zone points first: meeting deep is still better
}
// ---------------------------------------------------------- miss diagnostics ----
// Every player swing in tennis mode gets logged: HIT with quality, or MISS with the
// gate that rejected it and the ball's closest approach. Balls that pass without a
// swing log as PASSED when the point ends. Inspect live via T.G.missLog; the latest
// miss reason is drawn on the HUD (TUNE.missHud).
const r2 = x => Math.round(x * 100) / 100;
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 > 50) G.missLog.shift();
if (entry.result !== 'HIT') G.lastMiss = { text: entry.reason, at: G.idleT };
}
function logWhiff() {
const snaps = G.swingSnaps; G.swingSnaps = null;
if (G.mode !== 'tennis') 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.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, 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) });
}
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);
const t = ch.swingT;
if (t < TUNE.swingWindup || t > TUNE.swingWindup + win) return;
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;
if (Math.hypot(dx, dz * 0.8) > reach || ball.y > CONTACT.maxY) return;
if (ball.y < CONTACT.minY && ball.vy <= 0) return; // a DROPPING ball below the ankles is past you — but a rising one is a half-volley, always scoopable
// 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);
if (Math.abs(t - center) * 1000 < TUNE.perfectMs) q = 1;
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 (G.diff.smart, classic difficulty): 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 smart = isAI && G.diff && Math.random() < (G.diff.smart || 0);
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 || { ax: axisX(), az: axisZ() * (ch.side === 'P' ? 1 : -1) };
// G.diff (classic difficulty) moves the FLOOR too: a raised qMin is what makes HARD's every
// ball arrive with pace, where a cap alone just trims the best ones. Null outside classic, so
// the racket zombie and the gore opener keep the TUNE floor.
const qMin = G.diff ? G.diff.qMin : TUNE.aiQualityMin;
if (isAI) q = Math.min(q, qMin + Math.random() * (Math.max(qCap, qMin) - qMin));
if (!isAI && G.mode === 'tennis') {
G.swingSnaps = null; G.rallyNear = null;
logSwing({ result: 'HIT', reason: `contact q=${Math.round(q * 100) / 100}`, d: Math.round(Math.hypot(dx, dz * 0.8) * 100) / 100, y: Math.round(ball.y * 100) / 100 });
}
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
if (!incoming) { 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 = ball.y > 0.35 && ball.vy < 3 ? predictLanding() : null; // not while it is still climbing off the racket
const aimX = pred ? ball.x + (pred.x - ball.x) * TUNE.aiRead : ball.x;
const aimZ = pred ? ball.z + (pred.z - ball.z) * TUNE.aiRead : ball.z;
const tz = Math.max(1.5, Math.min(COURT.svcFar, aimZ)); // chase laterally, hold deep on the far court
const dx = aimX - rz.x, dz = tz - rz.z, d = Math.hypot(dx, dz);
if (d > 0.15) {
// THE MATCH ESCALATES HERE, not in hp: with regicide gone the pool is pure scoreboard,
// so a harder opponent is a FASTER one that flubs less. The BASE is boosted too — the duel's
// 2.8 m/s lumber was tuned for one sudden-death point against a fresh player, and across a
// whole game it read as "very weak and slow": opponent one already moves at matchSpeedBase.
// (the dials themselves live in rzChaseSpeed, which the rally-ending reach tests share)
const step = Math.min(rzChaseSpeed(rz) * dt, d);
rz.dist = (rz.dist || 0) + step; // ground covered — his walk cycle steps off this, not the clock
rz.x += dx / d * step; rz.z += dz / d * step; rz.runT += dt;
if (rz.pose !== 'swing') rz.pose = 'run';
// `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;
} else { rz.vx = 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 (rz.swingCool <= 0 && rz.swingT < 0
&& Math.hypot(ball.x - rz.x, (ball.z - rz.z) * 0.8) < TUNE.aiReach * wnd.reach
&& ball.y < CONTACT.maxY && ball.y > CONTACT.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, G.match.opp - 1) * (rz.foe?.flub || 1) * wnd.flub
: TUNE.racketFlub;
if (Math.random() < flub) rz.swingCool = 0.5; // whiff — it hesitates (rarer per rung of the ladder)
else { rz.swingT = TUNE.swingWindup + TUNE.swingWindow / 2; trySwing(rz, true, TUNE.racketQMax); rz.swingT = -1; rz.swingCool = 0.3; }
}
}
// 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++;
return Math.max(TUNE.meleeCoolMin, TUNE.meleeCool / (1 + Math.max(0, near - 1) * TUNE.meleeCrowd));
}
export function keyPressed(code) {
// 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)) {
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;
}
if (G.phase === 'rally' && P.swingT < 0) { P.swingT = 0; P.pose = 'swing'; P.poseT = 0; G.swung = 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) {
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) {
if (G.mode === 'tennis' && 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
const rate = TUNE.playerSpeed * aK * dt * (chasing ? 1 : 0.55);
// 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.
const tgt = chasing ? intercept('P')
: G.mode === 'splatter' && !G.match ? { x: P.x, z: HOME.P.z }
: HOME.P;
const dx = tgt.x - P.x, dz = tgt.z - P.z, d = Math.hypot(dx, dz);
if (d > 0.35) { // deadzone: stand ready near the spot instead of dithering over it
P.x += dx / d * Math.min(rate, d);
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);
}
// ...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.
else if (P.pose === 'run' && G.dodgeT <= 0) { P.pose = 'idle'; P.poseT = 0; }
// 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 (G.mode === 'tennis' && 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,
r: TUNE.reach * (volley ? VOLLEY.reach : 1), w: TUNE.swingWindow * (volley ? VOLLEY.window : 1) });
}
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);
if (P.swingT > TUNE.swingWindup + win + 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)
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') : 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;
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');
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 * TUNE.bounceY;
ball.vx *= TUNE.bounceXZ; ball.vz *= TUNE.bounceXZ;
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);
} 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
if (G.mode === 'splatter' && ball.active && !ball.scored && ball.lastHit === 'O' && ball.z > COURT.L + 0.3) {
ball.active = false;
if (G.interlude) resolveInterlude('O'); // 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);
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();
}
}
}