Files
splatter-tennis/src/beats.js
T
wuwiandClaude Opus 5 1b7fc2cdcf Both players run from a video, and the middle chip finally plays you
Six sessions of asking an image model for a row of running figures, and it
never once drew a gait: three phases repeated with the same leg leading,
"ropeskipping, not running". A frame-by-frame LEFT/RIGHT skeleton spec, a
closing checklist, a loop clause, six figures instead of eight — all lost.
One 5-second image-to-video clip has no such problem, because it moves a
body instead of drawing poses. ~$0.42 a character through the key already in
.env, pose pinned at BOTH ends (pinned only at the front, she rotated into
profile halfway), stride period MEASURED off the frames rather than sampled
by eye — hers repeats in 6 clip frames, his in 23 — and the phases cut and
palette-snapped through _stride.cut. assets/_vidroll.py films and cuts it;
her first frame came from one still roll that took his approved pose as a
picture instead of a paragraph.

Wired: runN probes the sheet through SK() so the women's tour counts HER
frames, the player is no longer clamped to a two-frame pair, runHz 2.4 ->
1.9 ("this looks like roadrunner"), and the bench grew two PLAYER stops —
a cycle only ever seen mid-rally is a cycle nobody has watched.

Also in: the difficulty retune (medium is a real step above the shipped CPU
and is where `smart` first appears; hard stepped back off superhuman; easy
closed its gap) with MEDIUM's dial-for-dial pin to TUNE removed from the
test, and Oliver's own transparency fixes on the male frames.

SPRV 138 — the frames were right on disk and wrong on screen for an hour
because assets are cached for a year unless the ?v= moves. main.js v269.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:53:58 +02:00

951 lines
59 KiB
JavaScript

// THE BEAT MACHINERY — every transition between the modes that can OWN THE COURT lives here:
// the sudden-death duel (G.interlude), the horde (G.horde), the chapter boss (G.bossFight), the
// changeover breather (G.changeover), and the clear-advance that hands play from one to the next
// (proceedAfterClear). THE RULE: one mode owns the court at a time, and every start* function
// enforces that itself — a caller never has to know what else is running. All three field freezes
// came from two of these overlapping; if you add a mode, it starts here and it refuses here.
// (beats <-> gameplay and beats <-> zombies are runtime-only import cycles, same pattern as
// input <-> gameplay: the imported bindings are only touched inside functions, never at load.)
import { TUNE, UI, bossArt, matchFoe, BENCH, ZFAMS, COURT, HOME, racketStage, CUP_ROUNDS } from './config.js';
import { endRun, MODE_MATCH } from './stats.js';
import { G, P, O, ball, score, setupPoint, sendBoy, other } from './state.js';
import { voice, zombieDuck, aww } from './audio.js';
import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie, explode, killPlayer } from './zombies.js';
import { cheer, aside, endBurst, deliverTin, showModal, logSwing, advancePoint, scoreKey } from './gameplay.js';
import { startDrillStream } from './drill.js';
import { netClose, netJudging, netSendPoint, netSendDead } from './net.js'; // runtime-only cycle: net calls back into startMatch/matchReady/netMatchWon
// the court is mopped up (or was already clear): play advances to whatever the won game earned —
// a changeover breather, a plain even-game beat, or (on a set) the centered SET card then a horde.
export function proceedAfterClear() {
G.clearing = false;
const mode = G.afterClear, side = G.clearWinner;
G.afterClear = null;
// A clear is the ONE moment the court is guaranteed empty — the natural slot for the scheduled
// duel. (A wall-clock timer would need its own "wait for a safe moment" and could fire mid-swarm.)
// The chapter boss outranks everything here, including a set: a boss walking on into a standing
// wave (or into a horde) would be unreadable. Like the duel it parks the interrupted beat.
if (G.bossDue) { const c = G.bossDue; G.bossDue = 0; G.afterDuel = mode || 'game'; startBossFight(c); return; }
// The set/horde beat outranks the duel; the duel just waits for the next clear.
if (mode !== 'set' && G.duelDue) { G.duelDue = false; G.afterDuel = mode || 'game'; startDuel(); return; }
if (mode === 'set') {
G.card = { title: 'SET ' + (G.sets[0] + G.sets[1]), // the set just banked (sets already ++'d at game over)
sub1: side === 'P' ? 'THE SET IS YOURS' : 'SET TO THEM',
color: side === 'P' ? UI.pick : UI.hot, t: TUNE.setCardT };
G.pendingHorde = true; // the horde begins when the card's pause ends
ball.active = false; G.phase = 'over'; G.overT = TUNE.setCardT;
} else if (mode === 'changeover') {
startChangeover();
} else { // 'game': even-game beat, then the next wave arms
ball.active = false; G.phase = 'over'; G.overT = TUNE.gameBeatT;
}
// (no 'GAME N' flash on either path anymore — the bar under the scoreboard prints the game
// number permanently, and the flash was the same news shouted a second time)
}
// changeover (after odd games, as the rules demand): a forced breather — no spawns,
// no serving, the boys mop. Zombies already on court keep walking; it's calm, not safe.
function startChangeover() {
G.changeover = TUNE.changeoverT; // seconds left, not a flag: "Time." is called when THIS runs out
G.phase = 'over'; G.overT = TUNE.changeoverT;
// out with the mop, across the near court where the gore is — NOT to the ball: a dead ball
// parked off the field (sendBoy's z guard) meant no boy came out at all, and the breather is
// now long enough that an empty court reads as the game having stalled.
sendBoy(0, COURT.L * 0.75, true); // near enough the camera to actually watch him work
// no CHANGEOVER flash — the countdown panel says 'CHANGEOVER · RESUMING IN n' for the whole
// breather (render), so the flash was the same word with a shorter lifespan
aside('ump_still_a_match', 2.6); // the breather is when he reminds you this is a match
// no tins here anymore — weapons are won in the duel, never handed out on a schedule
}
// ---- the interlude: order is restored, one sudden-death point vs the racket zombie.
// Tennis rules apply for exactly one point — the absurdity peak: the umpire never stopped watching.
// This is now the ONLY source of weapons in the game: win the point or leave empty-handed.
export function startDuel() {
// Hard mutual exclusion, HERE, where every caller routes through: a duel must never begin while
// another control mode owns the court. Each of these has ended a run when they overlapped.
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return;
if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // the schedule guarantees an opponent
for (const z of zombies) if (z.variant === 'r') z.dueled = true; // a veteran can be challenged again — this marks the fresh duel
G.weapon = 0; // tennis rules for the duel: plain balls, no tins
G.mgT = 0; endBurst(); pellets.length = 0; // ...and a burst in progress is over. The umpire has restored order.
G.interlude = { introT: 2.4 }; // the intro beat: serving blocked while the umpire announces
G.boy = null; // ...and the mop boy is waved off the court (sendBoy is barred for the duration)
ball.active = false; setupPoint();
zombieDuck(true); // fade the zombie music out for the 1:1 duel
voice('play_resume', 0.2); voice('sudden_death', 2.4);
if (!G.seen.duel) { // the rules change back — worth one real interruption, once per run
G.seen.duel = true;
showModal({ title: 'SUDDEN DEATH', color: UI.hot,
body: ["IT'S TENNIS AGAIN — ONE POINT", 'WIN IT AND THEY HAND YOU A WEAPON'] });
} else G.flash = { text: 'SUDDEN DEATH', t: 1.6 };
}
// ---- the chapter boss: every TUNE.bossEvery games, the court is cleared and ONE thing walks on.
// No other spawns until it is down, so the fight is legible and so banking a bomb for it is a real
// decision (which is the point — tins arrive every 3 games, so there was never a reason to save one).
// It threatens BOTH channels: its volleys chip your hp, and letting it reach you is the ordinary
// instant death. G.bossFight holds the chapter number while it runs.
export function startBossFight(chapter) {
if (G.bossFight || G.drill || G.match || G.bench || G.net) return;
G.bossFight = chapter;
zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; // a clean stage
G.horde = null; G.mgT = 0; endBurst(); G.clearing = false;
ball.active = false; setupPoint();
spawnChapterBoss(chapter);
zombieDuck(true); // the music drops out for it
aside('ump_boss_arrives'); voice('sudden_death', 2.6, 0.85); // the stake is an event; the quip is benched (see aside())
const art = bossArt(chapter);
// THE WALK-ON IS AN OVERLAY, not a band card. A card announced the biggest fight in the game in
// the same slot and size as a routine set, and it read as the boss simply being there. This is on
// the sanctioned modal list (a boss stage is guaranteed calm — the two lines above just cleared
// it), and it HOLDS itself for bossIntroT rather than waiting for a tap: the point is to prepare
// the player, and demanding a tap to start a fight he didn't ask for is the control steal the
// popup policy exists to prevent. A tap still skips it.
G.card = null; G.flash = null;
showModal({ title: art.name, color: UI.bad, hold: TUNE.bossIntroT, body: ['ENTERED THE COURT'] });
// (the four-line machine instruction modal is gone — people figure it out)
}
// the chapter boss is down: the parked game beat resumes and normal spawning comes back
export function endBossFight() {
if (!G.bossFight) return;
const c = G.bossFight; G.bossFight = 0;
zombieDuck(false);
shots.length = 0;
cheer(3.4, 0.9); voice('unbelievable', 0.3);
// hand back the game beat the boss interrupted FIRST — same contract resolveInterlude uses. It
// used to run LAST, and its 'set' branch writes a routine SET card and a short overT: on a game
// that is both a set and a boss chapter (game 30), the EPIC card below was overwritten before it
// had drawn a single frame, and the beat was cut to setCardT. The boss card wins now.
if (G.afterDuel) { G.afterClear = G.afterDuel; G.afterDuel = null; proceedAfterClear(); }
// The longest fight in the game deserves more than the two seconds a routine set gets. Big word,
// long hold — the chapter and the crowd move down to the subs where they belong.
// (no 'GAME n NEXT' line: the bar under the scoreboard prints the game number permanently, and a
// card that repeats what the HUD already says is the clutter this pass is meant to remove.)
G.card = { title: 'EPIC!', sub1: 'YOU DID IT!', color: UI.pick, t: TUNE.bossWinT,
wait: TUNE.bossDeathT }; // ...after the body has finished coming apart
ball.active = false;
// a duel the handback just started owns the phase; otherwise hold the boss beat, and never
// SHORTEN a longer one the handback set (a changeover's 5 s outlasts this)
if (!G.interlude) { G.phase = 'over'; G.overT = Math.max(G.overT, TUNE.bossWinT + TUNE.bossDeathT); }
}
// ---- THE COACHING DRILL: the cognitive mode. Same contract as every mode above — it refuses to
// start while anything else owns the court, and it clears the court it takes over, so nothing that
// was walking around a moment ago is still walking around inside a drill round. The drill's own
// clock and rules live in drill.js; only the transition is here, where the rule can be enforced.
export function startDrill() {
if (G.drill || G.interlude || G.horde || G.bossFight || G.match || G.bench || G.net) return false;
G.drill = { t: 0, spawnT: 0, score: 0, streak: 0, kills: 0, faults: 0, lost: 0, best: 0, msg: null };
G.changeover = false; G.clearing = false; G.afterClear = null;
// ...and DISARM the ordinary schedule. Taking the court is not enough on its own: a pending
// horde, an owed chapter or a due duel is a latch that keeps trying to fire behind the drill,
// and the mode it belongs to has no bodies of its own here to fire at.
G.pendingHorde = false; G.hordeWarn = 0; G.bossDue = 0; G.duelDue = false;
G.mgT = 0; endBurst(); pellets.length = 0; extras.length = 0; shots.length = 0;
G.boy = null; // nobody mops through a drill
G.weapon = 0; // plain balls: the mode is about order, not about the rack
ball.active = false; setupPoint();
voice('quiet_please', 0.1);
startDrillStream();
return true;
}
export function endDrill() {
if (!G.drill) return;
G.drill = null;
zombies.length = 0;
ball.active = false; setupPoint();
}
// ---- THE SPRITE BENCH: the back room's art test, and nothing else. No ball, no scoring, no AI —
// ONE opponent on an empty court, at the size and the speed it is actually played at, because that
// is the only place a walk cycle can be judged. A sheet that looks fine at 224 px on a contact sheet
// is a different animal at 30 px moving across a lawn, and every sprite argument this project has
// had was really an argument about that gap.
//
// Each stop is either an ANIMATED state (the walk, driven by ground covered like the real thing) or
// ONE NAMED FRAME, so frames the game cannot reach yet — the serve, the fall — can still be looked
// at the day they are generated rather than the day they are wired up.
const BENCH_HP = 12; // a deep pool so a wound stage is set by FRACTION, cleanly
const BENCH_CHARS = ['o', 'of', 'p', 'pf']; // the living slots past ZFAMS, in walk order
export function startBench() {
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return false;
G.bench = { i: 0, stage: 0, fam: 0, dir: 1 };
zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0;
// ...and DISARM the ordinary schedule, exactly as the drill does: a pending horde or an owed
// chapter is a latch that keeps trying to fire behind a mode that has no bodies for it.
G.pendingHorde = false; G.hordeWarn = 0; G.bossDue = 0; G.duelDue = false;
G.changeover = false; G.clearing = false; G.afterClear = null;
G.weapon = 0; G.mgT = 0; endBurst(); G.boy = null;
ball.active = false; setupPoint();
G.flash = null; G.card = null; G.modal = null; G.modalQ.length = 0; // nothing the run was
// saying belongs over an art test — the bench's own
// readout lives in that band
spawnRacketZombie();
benchApply();
return true;
}
export function endBench() {
if (!G.bench) return;
G.bench = null;
zombies.length = 0;
ball.active = false; setupPoint();
}
// hp is set as a FRACTION of the pool, never as a literal, because that is how drawZombie reads a
// wound stage — the bench must ask the same question the game asks or it is testing something else.
function benchApply() {
const zb = zombies.find(z => z.variant === 'r'); if (!zb) return;
// the four slots past ZFAMS are the LIVING players: the two opponents, then the two PLAYERS
// (the run cycles are re-rolled from the same script and judged the same way — a player cycle
// that was only ever seen in a rally is a cycle nobody has actually watched)
G.bench.char = G.bench.fam >= ZFAMS.length ? BENCH_CHARS[G.bench.fam - ZFAMS.length] : null;
zb.hpMax = BENCH_HP;
zb.hp = Math.max(1, Math.round(BENCH_HP * [1, 0.5, 0.2][G.bench.stage]));
// ...and the named states take the wound stage with them: z_r<suffix> at full health,
// z_rw1<suffix> / z_rw2<suffix> below it. render falls back to the healthy frame if a stage has
// not been drawn yet, and the HUD says so — a bench must never imply art that does not exist.
const f = ZFAMS[G.bench.fam] || 'r';
zb.fam = f;
const suf = BENCH[G.bench.i].spr;
zb.showFrame = suf ? 'z_' + f + (G.bench.stage ? 'w' + G.bench.stage : '') + suf : null;
}
// THE FALL BUTTON PLAYS THE REAL BEAT. Same FALL table, same tickMatchFall — the bench must never
// re-implement a timing it exists to judge, or it would be refining a second copy of it. `from` is the
// stage the body collapses IN; the rise reads racketStage(zb) off the hp the bench already set, so the
// sequence wears the wound stage currently on screen. Press it again to replay.
export function benchFall() {
if (!G.bench || G.matchFall) return false;
const zb = zombies.find(z => z.variant === 'r'); if (!zb) return false;
G.matchFall = { t: 0, from: G.bench.stage, vx: zb.vx || 0, x0: zb.x };
return true;
}
// the bench's own controls, taken before anything else reads the key: SPACE / a tap steps to the
// next state, left/right change the wound stage. One gesture, one meaning — the rule this project
// already holds itself to on touch.
export function benchKey(code) {
const b = G.bench;
if (code === 'ArrowLeft' || code === 'KeyA') b.stage = (b.stage + 2) % 3;
else if (code === 'ArrowRight' || code === 'KeyD') b.stage = (b.stage + 1) % 3;
// up/down walks the OPPONENT FAMILIES — plus two LIVING slots past the end (the tennis
// opponents m/f, Oliver's run-cycle finetune bench): the zombie steps aside and the real O runs
else if (code === 'ArrowUp' || code === 'KeyW') b.fam = (b.fam + ZFAMS.length + BENCH_CHARS.length - 1) % (ZFAMS.length + BENCH_CHARS.length);
else if (code === 'ArrowDown' || code === 'KeyS') b.fam = (b.fam + 1) % (ZFAMS.length + BENCH_CHARS.length);
// F plays the fall. Before the fallback below, which treats ANY other key as "next state" — a new
// control that forgets to land above that line silently steps the stop instead.
else if (code === 'KeyF') return benchFall();
else b.i = (b.i + 1) % BENCH.length;
benchApply();
}
export function updateBench(dt) {
const b = G.bench; if (!b) return;
const zb = zombies.find(z => z.variant === 'r'); if (!zb) return;
const item = BENCH[b.i];
zb.z = 5.5; // parked on the far court, where an opponent stands
zb.swingCool = 1; zb.swingT = -1; zb.poseT = 0; // nothing expires a pose out from under the bench
// ...and a body that is COLLAPSING is not pacing. tickMatchFall owns the frame while the fall runs,
// so the walk stop must stop walking or the corpse slides sideways through its own death.
if (G.matchFall) return;
// THE LIVING SLOTS: the zombie waits outside the frame while the real opponent runs the same
// back-and-forth the walk stop uses — same speed class as the classic CPU, so the cadence being
// judged is the cadence the game plays at. Every stop shows the run; the run is what the bench
// slot exists to tune.
if (b.char) {
zb.x = 40; zb.vx = 0; zb.pose = 'idle';
// 'p'/'pf' pace the PLAYER on the near court instead — same back-and-forth, his own end, and
// the sprite is twice the size there, which is the whole reason his cycle needs its own stop.
const ch = b.char[0] === 'p' ? P : O;
G.hideO = b.char[0] === 'p'; // splatter hid him when the match turned; the opponent
ch.z = ch === P ? HOME.P.z : 5.5; // stops un-hide, the player stops leave him out
const step = TUNE.aiSpeed * 0.7 * dt;
ch.x += b.dir * step;
ch.runT += dt;
if (Math.abs(ch.x) > 4.0) { b.dir *= -1; ch.x = Math.sign(ch.x) * 4.0; }
ch.facing = b.dir;
ch.pose = 'run';
if (ch === P) { O.pose = 'idle'; O.x = HOME.O.x; O.z = HOME.O.z; }
return;
}
O.pose = 'idle'; O.x = HOME.O.x; O.z = HOME.O.z; G.hideO = true; // fam stops: the living man steps back out
if (item.walk) {
// THE STRIDE STEPS OFF GROUND COVERED, like the real one — a bench that animated on a clock
// would be flattering exactly the sprites that foot-slide in play.
const step = TUNE.racketSpeed * 0.55 * dt;
zb.x += b.dir * step;
zb.vx = b.dir * step / dt; // so a FALL from here inherits the run, as in a match
zb.dist = (zb.dist || 0) + step;
if (Math.abs(zb.x) > 4.0) { b.dir *= -1; zb.x = Math.sign(zb.x) * 4.0; }
zb.face = b.dir;
zb.pose = 'run';
} else {
zb.pose = item.pose || 'idle';
zb.x = 0; zb.vx = 0;
}
}
// ---- THE DEADLOCK WATCHDOG.
// A run that hard-locks costs the player everything, and this file has form: `mgT` was once left
// above zero by a beat that moved the phase underneath it, and `mgT > 0` blocks serving, so the
// game sat there with the gun racket on screen. This is the net.
//
// It cannot misfire on a legitimate wait, and that is the whole design: every legitimate reason you
// cannot serve — a horde, a burst, a changeover, the duel's announcement, an 'over' beat — has a
// CLOCK counting down toward letting you act. So the test is not "how long has the player been
// blocked" (a horde blocks you for fifteen honest seconds); it is "is the thing that should be
// releasing me actually moving". If none of those numbers changes for stuckT seconds while the
// player is blocked and no popup is waiting on them, the state is dead and nothing will revive it.
//
// It also LOGS what was frozen, into the same miss log the scoreboard tap already shows — a lock
// that unsticks itself and leaves no evidence is worse than one that does not.
let stuckT = 0, stuckSig = '';
export function watchdog(dt) {
if (G.mode !== 'splatter' || G.modal) { stuckT = 0; return; } // a popup waits on the PLAYER: legitimate
// INVARIANT NET, before the clock-based test — because the clock-based test is blind to exactly
// this: a horde's own timer keeps ticking, so the sig always "moves" while the player is welded
// in place. Two states the guards above should make impossible; if a path still reaches them,
// repair on the spot and leave evidence in the miss log.
if (G.horde && G.interlude) { // a duel inside a horde: no steering, no serving, spawns keep coming
logSwing({ result: 'INVARIANT', reason: 'duel active during horde — interlude dropped, duel re-scheduled' });
G.interlude = false; G.duelDue = true; G.duelLerp = 0;
}
if (G.horde && G.phase !== 'rally') { // pretoss under a horde = the finger steers nothing
logSwing({ result: 'INVARIANT', reason: `phase '${G.phase}' during horde — forced back to rally`, phase: G.phase });
G.phase = 'rally'; ball.active = false;
P.pose = 'idle'; P.poseT = 0; P.swingT = -1;
}
const blocked = G.horde || G.mgT > 0 || G.changeover || G.phase === 'over'
|| (G.interlude && G.interlude.introT > 0)
|| (G.phase === 'toss' && ball.y < 2.0); // a toss on the floor: only a held aim keeps it there
if (!blocked) { stuckT = 0; return; }
const sig = [G.horde && G.horde.t, G.mgT, G.changeover, G.interlude && G.interlude.introT,
G.phase === 'over' && G.overT, ball.y].map(v => (+v || 0).toFixed(2)).join('|');
if (sig !== stuckSig) { stuckSig = sig; stuckT = 0; return; } // something is still moving — fine
if ((stuckT += dt) < TUNE.stuckT) return;
logSwing({ result: 'UNSTUCK', reason: `nothing moved for ${TUNE.stuckT}s — released a dead latch`,
phase: G.phase, sig, aim: !!G.aim });
G.horde = null; G.mgT = 0; G.changeover = false; G.interlude = false; G.hordeWarn = 0;
G.aim = null; endBurst(); setupPoint();
stuckT = 0; stuckSig = '';
}
// ---- ZOMBIE TENNIS: the exhibition match. One GAME per opponent, real 15/30/40 scoring, and the
// score IS the body — every point you win tears a wound stage off the racket zombie (the same
// fraction-of-hpMax skins the duel uses), every point it wins draws your blood (the ordinary hp
// channel; at zero you are eaten as usual). Win the game and the corpse comes apart; a fresh
// opponent shambles on with more hp. Games won stays the ladder metric.
//
// Deliberately built ON TOP of the duel machinery rather than beside it: G.interlude is what makes
// serving tennis-like, keeps the reticle and the rack away, quiets the spawn director and wakes
// racketAI — all already true. The ONE difference is where a finished point goes: resolveInterlude
// hands it to matchPoint instead of ending anything, and the interlude simply never resolves until
// the match mode ends. Regicide is inherited for free: smashing the ball into the body still wounds
// it, and killing it outright wins the game early (killZombie fires resolveInterlude('P'), which
// lands in matchGameWon via the empty-court check).
// `draw` is the bracket screen's settled rungs, one per CUP_ROUNDS entry (main.buildBracket). It is
// optional and null means "roll each round as it comes", which is what the mode did before the
// screen existed and is still what the #cup back door gets.
export function startMatch(cup = false, draw = null) {
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return false;
G.match = { opp: 1, ending: false };
// THE TOURNAMENT rides the whole match machinery and differs in exactly three places: who walks
// on (a rung DRAWN from the round's band — cupDraw sets d.opp, so every ladder dial downstream
// scales off the drawn rung untouched), how deep the body's pool is (matchOpponent multiplies
// by the round's `need`), and where a won game goes (cupGameWon). See config.CUP_ROUNDS.
if (cup) { G.match.cup = { round: 0, g: 0, draw: draw || null }; cupDraw(); }
G.matchFall = null;
G.hp = G.hpMax = TUNE.matchPlayerHp; // a real match is longer than four mistakes — the gore
// mode's 4 was "too weak" here, felt not calculated
score.server = 'P'; // you serve every point (the corpse never learned to toss)
G.weapon = 0; G.mgT = 0; endBurst(); pellets.length = 0; extras.length = 0; shots.length = 0;
zombies.length = 0;
G.boy = null;
score.pts = [0, 0];
matchOpponent();
matchReady();
zombieDuck(true);
voice('play_resume', 0.2);
if (cup && !G.seen.cup) {
G.seen.cup = true;
showModal({ title: 'THE TOURNAMENT', color: UI.hot,
body: ['FOUR ROUNDS UP THE DEAD HIERARCHY',
'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'] });
} else if (!cup && !G.seen.match) {
G.seen.match = true;
// Oliver's copy direction: name the stake, count the blood. TUNE.matchPlayerHp is the real
// number, so the modal can never drift from the pool it describes.
showModal({ title: 'DEATH MATCH', color: UI.hot,
body: ['WIN THE POINT — WOUND THE ZOMBIE',
'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'] });
}
return true;
}
// The round's rung, drawn from its band. d.opp carries it so matchFoe, the ladder speed dials and
// the felled arithmetic all read the drawn rung with no cup-awareness of their own.
// A DRAW MADE ON SCREEN IS THE DRAW THAT IS PLAYED. When the bracket screen settled the rungs they
// arrive here as cup.draw and this simply reads them off — rolling again would mean the faces the
// player watched land had nothing to do with what walks on, which is worse than showing nothing.
// Guarded on the band all the same: a saved or hand-passed draw that names a rung this round does
// not contain falls back to an honest roll rather than dealing the champion in round one.
function cupDraw() {
const cup = G.match.cup, R = CUP_ROUNDS[cup.round];
const drawn = cup.draw && cup.draw[cup.round];
cup.rung = R.band.includes(drawn) ? drawn : R.band[Math.floor(Math.random() * R.band.length)];
G.match.opp = cup.rung;
}
// Every point starts like a real one: the server behind his line (setupPoint's tennis branch) and
// THE RECEIVER ON ITS MARK — the zombie is dropped onto the receiver position the tennis branch
// just computed for O, instead of standing wherever the last rally abandoned it. Oliver: "it spawns
// at places where it is impossible to react to my serve. so i win like always." An opponent that
// cannot reach the serve is not weak, it is absent.
// ...and G.serveNum resets HERE, because the match bypasses tennis's pointTo (the only other place
// that resets it). Without this, the first fault of the match armed 'second serve' PERMANENTLY:
// every missed serve after it was a double fault, a point, and blood — the death loop Oliver hit.
export function matchReady() { // exported for net.js: a paired death match re-places both bodies
ball.active = false;
G.serveNum = 1;
if (G.match) G.match.decided = false; // the next rally may be decided early again
setupPoint();
const rz = zombies.find(z => z.variant === 'r');
// THE OPPONENT STANDS WHERE THE POINT PUTS IT: on its receiving mark, or behind its own baseline
// when it is the one serving. setupPoint has just placed O — the invisible tennis body the serve
// machinery is driven off — on exactly that spot, so the zombie simply takes it. One source for
// the mark, which is what stops the picture and the ballistics disagreeing about where it stands.
if (rz) {
rz.x = O.x; rz.z = score.server === 'O' ? O.z : 1.0;
rz.pose = 'idle'; rz.swingT = -1; rz.swingCool = 0.3;
rz.showFrame = null; rz.serveHold = 0;
}
}
// ---- THE FALL. A point won knocks the body DOWN and it drags itself back up MINUS THE LIMB.
//
// It exists to explain a thing the mode was doing silently: the wound stage POPPED between points,
// so an arm simply stopped being there and nothing said why. Now the three collapse frames are the
// body it STILL WAS and the rise is the body it has BECOME — the order is the whole beat, and it is
// what makes the missing arm read as something the point did rather than a sprite swap.
//
// On its OWN clock, deliberately, and not on the 'over' beat it also sets. Any beat that moves the
// phase underneath a latch strands it forever — that is the mgT lock this file already carries a
// section about, and the horde's warning is on its own clock for the same reason. The 'over' beat is
// only there to keep the court quiet, and it is set to outlive the fall so it cannot expire into
// setupPoint() mid-collapse and hand the player a serve over a body that is still on the ground.
// fall3 is the one that HOLDS: "the opponent should fall and then lie there for a quick moment, the
// player stands up too quick." A corpse that touches the ground and is instantly upright reads as a
// stumble; the beat only lands if it is down long enough to look beaten.
// "fall quicker. lie there for 2s then stand up." The collapse is now a drop — two frames in a fifth
// of a second — and the time is spent on the floor, where it reads.
// EXPORTED AND MUTABLE, so the sprite bench's FALL button can be tuned against the art while it
// plays — refining these four numbers IS the job that button exists for, and a bench that could not
// reach them would only prove the beat runs, not that it feels right.
// TUNED IN #bench WITH THE FALL BUTTON, watching it against the art rather than reasoning about it.
// fall1 and fall2 are deliberately the SAME PACE (0.87 and 0.88 m/s of slide at a 1.54 m/s entry), so
// the collapse reads as one continuous movement decelerating into the ground; the 0.07 s fall2 it
// replaced gave the two drawn frames ~4 display frames each and read as a cut, and a 0.40 s one split
// them 0.80 against 0.56 m/s, which is a visible hitch between two poses of the same drop.
// 2026-08-13, after the early-fall retiming: "the progression of frames in the fall must be
// quicker... we must remove the rise frame, the opponent respawns anyway... the lying itself can
// stay that long." So: the DROP tightened (0.06+0.25 -> 0.05+0.15 — the collapse now plays
// against a live rolling ball, and the slower drop tuned for a dead court read as mush next to
// it), the 2 s prone hold KEEPS its bench-tuned length, and the rise row is CUT: done() puts the
// body on its mark in its wound skin anyway, and rising just to be teleported read as a respawn
// with extra steps. The z_*rise frames stay in the sheets/preload — the bench can still show them.
export const FALL = [['fall1', 0.05], ['fall2', 0.15], ['fall3', 2.0]];
// Read LIVE by startFall, because a snapshot goes stale the moment the table is tuned and the match's
// 'over' beat would then expire mid-collapse — the exact bug the comment above is about.
export const fallLen = () => FALL.reduce((s, f) => s + f[1], 0);
export const FALL_LEN = fallLen(); // the load-time value test.js imports as a loop bound
function startFall(from) {
const fz = zombies.find(z => z.variant === 'r');
G.matchFall = { t: 0, from, vx: (fz && fz.vx) || 0, x0: (fz && fz.x) || 0 };
// an EARLY-decided point keeps its ball: the whole reason the fall starts now is so the ball
// visibly rolls past the collapsing body (gameplay's matchOutOfReach call). The rally cannot
// resolve twice — the d.decided latch swallows every later resolution of it.
if (!G.matchEarly) ball.active = false;
G.phase = 'over'; G.overT = fallLen() + 0.6; // outlives the fall on purpose — see above
}
// Ticked from update(), beside the horde's warning, for the same reason it is. It also drives the
// OPPONENT'S SERVE POSES, because both are the same job — naming the frame while the body is doing
// something the walk cycle has no picture for.
export function tickMatchFall(dt) {
// THE MOP BEAT runs here, on the same clock discipline as the fall: its own timer, never the
// 'over' beat (see the section note above). When the boy's time is up the next opponent walks
// on with its own intro card, exactly as it used to on the kill frame.
const d = G.match;
if (d && d.mopT > 0 && (d.mopT -= dt) <= 0) {
// Time is up — but THE COURT IS HIS UNTIL HE LEAVES IT (Oliver: "no playing as long as the
// mopboy is on court"). He is ordered off (jobs zeroed, the half-pushed stain dropped) and
// the walk-on holds until he is clear of the playing area; the 6 s cap is a belt on top of
// the braces — nothing should ever need it, but a stalled beat must not stall the match.
const b = G.boy;
if (b && b.mop && Math.abs(b.x) < 6.5 && (d.mopHold = (d.mopHold || 0) + dt) < 6) {
b.jobs = 0; b.tx = undefined; b.tz = undefined; b.mopT = 0;
if (G.interlude && G.interlude.introT < 1) G.interlude.introT = 1; // serving stays barred
d.mopT = 0.001; // the beat re-checks next tick
} else {
d.mopT = 0; d.mopHold = 0;
matchOpponent();
d.ending = false; // play re-opens only now that a body is on court
matchReady();
G.phase = 'over'; G.overT = 2.2; // matchReady's setupPoint re-arms pretoss; keep the old beat
}
}
if (!G.matchFall) return tickMatchServe(dt);
const f = G.matchFall;
const zb = zombies.find(z => z.variant === 'r');
// THE BENCH OWNS THE COURT WHEN IT IS OPEN, so neither exit hands a point back: matchReady() runs
// setupPoint() and re-places the body on its receiving mark, which is a MATCH doing match things
// inside an art test. Same one-mode-owns-the-court rule the start* functions all obey.
const done = () => { G.matchFall = null; if (zb) zb.showFrame = null; if (!G.bench) matchReady(); };
if (!zb && !f.kill) { G.matchFall = null; if (!G.bench) matchReady(); return; } // felled by something else mid-fall
f.t += dt;
// A GAME-WINNING FALL DOES NOT GET UP. The explosion used to land on the same frame as the point,
// so the collapse never played (Oliver: "it would be better if the explosion would happen after
// the fall"). Now the body drops, holds a beat prone, bursts — and only THEN does the deferred
// finish run (d.afterFall: the heal card, the mop beat, the walk-on), so the WOUNDS TREATED
// popup lands after the burst instead of covering it.
if (f.kill) {
const boomAt = FALL[0][1] + FALL[1][1] + TUNE.matchBoomHold;
if (!f.boomed && (!zb || f.t >= boomAt)) { f.boomed = true; if (zb) explode(zb.x, zb.z); }
if (f.boomed) {
if (f.t < boomAt + TUNE.matchBoomCard) return; // let the burst be seen before any card
G.matchFall = null;
const fin = d.afterFall; d.afterFall = null;
return fin && fin();
}
}
let acc = 0, cur = null;
for (const [suf, len] of FALL) { acc += len; if (f.t < acc) { cur = suf; break; } }
if (!cur) return done(); // back on its feet: hand the point back
// ...and this is the line the beat is for: the collapse wears the OLD stage, the rise wears the
// NEW one. drawZombie's showFrame override degrades a missing stage to the healthy frame, so a
// family whose fall rows have not been drawn yet still plays the beat instead of blinking out.
// A BODY RUNNING FOR THE BALL DOES NOT STOP DEAD TO FALL OVER. It carries the run into the collapse
// and skids out of it — Oliver: "when the opponent runs to the ball from left to right and then
// falls, the player must still move to the right while falling." The velocity is INHERITED (racketAI
// and updateBench both record zb.vx), so a body that went down standing still does not slide at all
// and one caught at a sprint slides furthest.
//
// THE SLIDE IS A DISTANCE BUDGET SHARED OUT ACROSS THE FRAMES, not a decaying velocity. It was
// exponential decay first, and that put 93% of the travel in the first two frames: "the visible
// slide should also extend to the 3rd and 4th frame, between those the slide is barely noticeable."
// A wall-clock decay cannot fix that by retuning — stretch it far enough to move the prone body and
// it creeps for the whole 1.8 s floor hold, which is a corpse on ice. So each frame gets an explicit
// SHARE of the total (TUNE.fallSlideMix), eased out within its own segment so it still decelerates:
// the drop carries, the landing skids, and the rise drags itself the last of the way.
// x is set ABSOLUTELY from the start position rather than integrated, so the total cannot drift with
// frame rate and the same fall lands in the same place every time.
if (f.vx) {
const mix = TUNE.fallSlideMix, total = f.vx * TUNE.fallSlideDist;
let acc2 = 0, seg = 0, segStart = 0;
for (let i = 0; i < FALL.length; i++) { // which segment, and how far into it
if (f.t < acc2 + FALL[i][1]) { seg = i; segStart = acc2; break; }
acc2 += FALL[i][1]; seg = i; segStart = acc2 - FALL[i][1];
}
const len = FALL[seg][1] || 1e-6;
const u = Math.max(0, Math.min(1, (f.t - segStart) / len));
const ease = 1 - (1 - u) * (1 - u); // ease-out: quick, then settling
let done2 = 0;
for (let i = 0; i < seg; i++) done2 += mix[i] || 0;
const travelled = total * (done2 + (mix[seg] || 0) * ease);
zb.x = Math.max(-COURT.dblW / 2, Math.min(COURT.dblW / 2, f.x0 + travelled));
}
const st = cur === 'rise' ? racketStage(zb) : f.from;
zb.showFrame = 'z_' + (zb.fam || 'r') + (st ? 'w' + st : '') + cur;
zb.pose = 'idle'; zb.swingT = -1;
}
// ---- THE OPPONENT SERVES. Alternating service is real tennis, and it doubles the mode's skill
// surface: until now the corpse only ever RETURNED, so return timing — half of the sport — was never
// asked for. Almost all of it already existed and was switched off: setupPoint's tennis branch
// already places a non-player server behind its own baseline and arms G.aiServeT, and update()
// already tosses and strikes for O. Both were gated on `G.mode === 'tennis'`, and the match runs in
// splatter with a permanent interlude, so the whole machine was one condition away from running.
//
// What is genuinely new is the PICTURE. O is invisible here — the body you see is the zombie — so
// the serve poses have to be named onto it, exactly as the fall names its collapse: bounce while it
// waits, toss as the ball goes up, and the strike held briefly after contact so the swing reads as
// something the body did rather than a ball that simply departed.
function tickMatchServe(dt) {
if (!G.match || score.server !== 'O') return;
const zb = zombies.find(z => z.variant === 'r');
if (!zb) return;
if (zb.serveHold > 0) zb.serveHold -= dt;
const st = racketStage(zb);
const pre = 'z_' + (zb.fam || 'r') + (st ? 'w' + st : '');
zb.showFrame = zb.serveHold > 0 ? pre + 'serve'
: G.phase === 'pretoss' ? pre + 'bounce'
: G.phase === 'toss' ? pre + 'toss'
: null;
}
function matchOpponent() {
// NETPLAY: THE OPPONENT IS THE FRIEND, NOT A ROSTER BODY (Oliver: "it should be the normal
// opponent"). Nothing spawns, nobody is renamed, no walk-on card — the living O carries the
// whole picture and the score reads on it as BLOOD (bleedO in zombies.js), because the human
// sprite has no wound skins to climb. This beat only returns the body after a burst and
// re-arms the intro bar.
if (G.net) {
G.hideO = false; // the burst took him; the walk-on returns him
G.flash = { text: 'IT RISES AGAIN.', t: 1.8 };
G.interlude = { introT: 1.6 };
return;
}
spawnRacketZombie();
const zb = zombies.find(z => z.variant === 'r');
zb.dueled = true; // wakes racketAI, same as a fresh duel
// WHO THIS ONE IS. The opponent carries a name off the chapter roster and a set of dial scalings —
// pure data, read where the dials are already read (see matchFoe in config). It rides on the BODY
// so killZombie can file the kill under it without asking G which opponent we were on.
zb.foe = matchFoe(G.match.opp);
zb.fam = zb.foe.fam; // ...and the body it wears (config.ZFAMS)
// the walk-on card wears the opponent's own head (config.HEAD_CROP — a crop, not new art).
// 4.0 s: at 2.4 the head + name + punchline were gone before they were read ("the intro card
// of the player should last longer"). In the cup the round names itself on the third line.
const cup = G.match.cup;
G.card = { title: zb.foe.name, sub1: zb.foe.line, color: UI.hot, t: 4.0, head: zb.fam,
sub2: cup ? CUP_ROUNDS[cup.round].name + (CUP_ROUNDS[cup.round].need > 1
? ' · FIRST TO ' + CUP_ROUNDS[cup.round].need + ' GAMES' : '') : undefined };
// a FLAT pool per body: with regicide gone the body is pure scoreboard (matchPoint takes a
// game's-quarter per point), so a deeper pool would change nothing — the ladder escalates in
// racketAI instead (matchSpeedPer / matchFlubKeep). THE CUP MULTIPLIES BY THE ROUND'S NEED and
// matchPoint divides the same way, so the wound stages spread across the whole round instead of
// being spent in game one — the body enters game two already missing what game one cost it.
zb.hp = zb.hpMax = TUNE.matchOppHp * (cup ? CUP_ROUNDS[cup.round].need : 1);
G.interlude = { introT: 1.6 }; // the duel machinery IS the match machinery
}
export function endMatch() {
if (!G.match) return;
G.match = null; G.interlude = false; G.matchFall = null; // a latch left armed outlives its mode
zombies.length = 0; shots.length = 0;
zombieDuck(false);
ball.active = false; setupPoint();
}
// One tennis point is settled — the ONLY entry is resolveInterlude's hook below, so every way a
// point can end (bounce out, double bounce, a body hit, a whiff) funnels through the duel's own
// plumbing untouched.
function matchPoint(side) {
const d = G.match;
if (d.ending) return;
const zb = zombies.find(z => z.variant === 'r');
let fellFrom = -1;
if (side === 'P' && zb) {
// a QUARTER of its pool per point, floored at 1 — four points walk it through every wound stage
// and only the game-winning point actually fells it, so the body and the scoreboard agree all
// the way to the end. (Ball hits wound it on top of this; that is the regicide path, kept.)
const was = racketStage(zb);
const div = 4 * ((d.cup && CUP_ROUNDS[d.cup.round].need) || 1); // the cup spreads the pool over the round
zb.hp = Math.max(1, zb.hp - Math.ceil(zb.hpMax / div));
// ONLY when the stage actually moved. A point that chips the pool without crossing a threshold
// has no limb to explain, and a second of collapse for nothing is a beat the player waits out.
if (racketStage(zb) !== was) fellFrom = was;
}
if (side === 'O') {
G.hp--; G.hurtT = TUNE.hurtGrace; G.hurtFlash = 0.6;
aww(0.8);
// ...and the umpire notices, drily. Situational, never a pool on a timer: the LOW line only
// exists once the blood is nearly gone, so it lands as a warning rather than as commentary.
// AFTER the score call, not under it. voice() drops any line that would start while the umpire
// is still talking (one man, one microphone) — and matchPoint calls scoreKey() at 0.4 s with a
// clip over a second long, so every one of these was being swallowed silently.
aside(G.hp <= 2 ? 'ump_low_blood' : 'ump_wound_you2', 2.2);
if (G.hp <= 0) {
// netplay: my blood is bookkept only on MY phone, so my death has to be said out loud —
// the other phone turns it into its win screen (netMatchWon)
if (G.net && G.net.ready) netSendDead();
return killPlayer(); // eaten — the run summary, as ever
}
}
const over = advancePoint(side); // banks the game and resets the points itself
if (over) return side === 'P' ? matchGameWon(zb, true) : matchGameLost();
voice(scoreKey(), 0.4);
if (side === 'P') cheer(0.8, 0.3);
if (fellFrom >= 0) {
// A LIMB IS WORTH A REMARK. Two lines, alternating on the stage taken, so the same sentence
// never lands twice in one game — a repeated aside reads as a stuck HUD, the same reason the
// praise words cycle rather than roll.
aside('ump_wound_opp', 2.2); // ...after the score call, see above
return startFall(fellFrom); // it goes down, and gets up short of a limb
}
matchReady(); // next point, same opponent, both on their marks
}
// a clean game is how you last — and when it actually restores blood, the trainer SAYS so
// (Oliver: "show wounds healed popup in zombie tennis when appropriate"). Same popup as the
// duel's, so healing has one face everywhere; a full-blood win stays silent.
function matchHealBeat() {
const healed = Math.min(G.hpMax, G.hp + TUNE.matchHeal) - G.hp;
G.hp += healed;
if (healed > 0) {
// NETPLAY: a CARD, never the modal — a modal freezes THIS world while the other phone's keeps
// moving, and an unread popup would hold the opponent's next serve hostage (the popup policy's
// live-court rule, applied to a court that is live on someone else's screen).
if (G.net) G.card = { title: 'WOUNDS TREATED', sub1: G.hp + ' OF ' + G.hpMax + ' BACK', color: UI.pick, t: 3.0 };
else showModal({ title: 'WOUNDS TREATED', icon: 'x_cross', iconH: 52, color: UI.pick,
body: ['THE TRAINER PATCHED YOU UP', G.hp + ' OF ' + G.hpMax + ' BACK'] });
}
}
// The game-winning point fells the body but no longer bursts it on the same frame: the KILL FALL
// plays first (same FALL table, kill-flagged), tickMatchFall detonates it after the prone hold,
// and only then runs d.afterFall — the caller's deferred finish. Regicide arrives with the body
// already gone (it came apart mid-game), so there is nothing left to fall: finish immediately.
function startKillFall(zb) {
const d = G.match;
if (!(zb && zombies.includes(zb))) { const fin = d.afterFall; d.afterFall = null; return fin(); }
G.matchFall = { t: 0, from: racketStage(zb), vx: zb.vx || 0, x0: zb.x, kill: true };
if (!G.matchEarly) ball.active = false;
// serving stays barred through the fall and the burst; afterFall re-arms the real beat's bar
G.interlude = { introT: 4 };
G.phase = 'over'; G.overT = 4;
}
function matchGameWon(zb, banked = false) {
const d = G.match;
if (d.cup) return cupGameWon(zb, banked);
d.ending = true; // killZombie in the burst re-enters resolveInterlude
// REGICIDE arrives here without advancePoint having banked anything (the body died mid-game, not
// on a game point) — bank the game by hand or the scoreboard drifts from the ladder.
if (!banked) { score.games[0]++; score.pts = [0, 0]; }
G.runGames++; // the ladder metric, same as every mode
voice('game_player', 0.3); cheer(2.4, 0.6);
// GAME is the FLASH and the next opponent's name plate is the CARD. It used to be the other way
// round and matchOpponent's plate landed on the same slot a frame later, so 'GAME.' never drew
// once — and the plate is the better card anyway: it announces the win by replacing the loser.
G.flash = { text: 'GAME.', t: 1.4 };
// netplay: the SAME opponent walks back on — the body across the net is one particular friend,
// and handing them the roster's next name and face would break the fiction (the ladder dials the
// opp number drives are AI-only and dormant here anyway)
if (!G.net) d.opp++;
// ALTERNATING SERVICE, as the sport has it. The match calls advancePoint directly, which banks
// the game but never touches the server — that flip lives in tennis's own pointTo, which the
// match bypasses (the same shape as the G.serveNum bug this file already carries a note about).
score.server = other(score.server);
// NETPLAY: the human body has no fall art to play, so the game-winning point bursts him where
// he stands (procedural gore is the one sanctioned fake) and the mop beat runs over the gap;
// matchOpponent's net branch un-hides him when the beat hands the court back.
if (G.net) { explode(O.x, O.z); G.hideO = true; }
// Everything past the burst waits for it — the heal card, the boy, the walk-on clock.
d.afterFall = () => {
matchHealBeat();
// ...and the boy comes out to deal with what four wound stages left on the far court. Deliberately
// the FAR side: that is where the body has been standing and bleeding, and it keeps him out of the
// near court where the player is about to serve. Between games only — see sendBoy's match note.
sendBoy(0, 4.5, true);
// THE MOP BEAT (Oliver: "mopboy must mop for 5 seconds and really remove some gore visibly
// before the new opponent arrives"). The fresh opponent used to spawn on the same frame as the
// kill, so the boy always mopped behind the next game's back. Now the court belongs to him
// first: the full job allowance (a between-games sendBoy books one stain), and matchOpponent
// waits on d.mopT (ticked in tickMatchFall). A court with nothing worth mopping skips the wait —
// an empty beat is a stall, not a show.
if (G.boy && G.boy.mop) { G.boy.jobs = TUNE.boyMaxJobs; d.mopT = TUNE.matchMopT; }
else d.mopT = 0.6;
// Serving stays blocked for the whole beat: the match's permanent interlude carries it, and
// keyPressed's introT bar sits ABOVE its over-beat re-arm, so a tap cannot serve at an empty
// court. matchOpponent replaces this object and re-arms the normal 1.6 s intro when it runs.
G.interlude = { introT: d.mopT + 1.6 };
// d.ending STAYS TRUE for the whole beat — the empty-court check in resolveInterlude reads
// "no body + not ending" as regicide, so a stray resolve during the beat would bank a phantom
// game. The walk-on tick is what re-opens play, exactly where the old code did.
G.phase = 'over'; G.overT = d.mopT + 2.2; // ticks down through the beat; reset at the walk-on
};
startKillFall(zb);
}
// THE CUP'S GAME. Mid-round: the same body plays on, wearing what the games have cost it. Round
// won: the kill, the mop beat, and the next round's draw — or, past the final, the trophy.
function cupGameWon(zb, banked = false) {
const d = G.match, cup = d.cup, R = CUP_ROUNDS[cup.round];
d.ending = true;
if (!banked) { score.games[0]++; score.pts = [0, 0]; }
G.runGames++;
voice('game_player', 0.3); cheer(2.4, 0.6);
cup.g++;
if (cup.g < R.need) {
// the round is still alive: no kill, no mop beat — the body keeps its wounds and its feet
matchHealBeat();
G.flash = { text: 'GAME.', t: 1.4 };
G.card = { title: R.name, sub1: 'YOU LEAD ' + cup.g + ' OF ' + R.need + ' — SAME BODY, FEWER PIECES',
color: UI.pick, t: 3.0 };
score.server = other(score.server);
d.ending = false;
matchReady();
G.phase = 'over'; G.overT = 2.2;
return;
}
// the round is won: the corpse goes down, and THEN comes apart (the kill fall, as the deathmatch)
d.afterFall = () => {
matchHealBeat();
cup.round++; cup.g = 0;
if (cup.round >= CUP_ROUNDS.length) return cupWon();
G.flash = { text: R.name + ' — YOURS.', t: 1.8 };
// the mop beat, exactly as the deathmatch runs it: the boy owns the court, then the next draw
sendBoy(0, 4.5, true);
if (G.boy && G.boy.mop) { G.boy.jobs = TUNE.boyMaxJobs; d.mopT = TUNE.matchMopT; }
else d.mopT = 0.6;
G.interlude = { introT: d.mopT + 1.6 };
cupDraw(); // the next round's rung -> d.opp
score.server = other(score.server);
G.phase = 'over'; G.overT = d.mopT + 2.2; // d.ending stays true until the walk-on tick
};
startKillFall(zb);
}
// CHAMPION OF THE DEAD CLUB — the run's other ending, on the classic win's whole shape (a held
// summary over a still court, then the gated restart). Banked through the same endRun as every
// match run, not dead: the stats screen's deaths column keeps meaning what it means.
function cupWon() {
const d = G.match;
d.ending = false; d.mopT = 0;
G.interlude = false;
ball.active = false; G.flash = null; G.modal = null; G.card = null;
cheer(4, 1); voice('unbelievable', 0.4);
const games = score.games[0] + score.games[1];
const b = endRun(CUP_ROUNDS.length, games, G.splatT, false, MODE_MATCH);
G.best = b; G.newBest = b.newBest;
d.felled = CUP_ROUNDS.length; d.games = games; d.best = b.games;
G.done = { won: true, t: 0, cup: true };
}
// NETPLAY'S WIN ENDING: the other phone said 'dead' — their player bled out, so this side is the
// last one standing. The classic win's whole shape (held summary, gated tap), banked NOT-dead
// through the same endRun as every match run; felled = the games that actually burst the body.
export function netMatchWon() {
if (!G.match) return;
const d = G.match;
d.ending = false; d.mopT = 0;
G.interlude = false; G.matchFall = null;
ball.active = false; G.flash = null; G.modal = null; G.card = null;
cheer(4, 1); voice('unbelievable', 0.4);
const games = score.games[0] + score.games[1];
const b = endRun(score.games[0], games, G.splatT, false, MODE_MATCH);
G.best = b; G.newBest = b.newBest;
d.felled = score.games[0]; d.games = games; d.best = b.games;
G.done = { won: true, t: 0 };
}
function matchGameLost() {
// Reachable only through deuce (four straight points is exactly your hp) — the body keeps its
// wounds, the scoreboard banks their game, and the match simply plays on.
voice('game_opponent', 0.3); aww(1);
score.server = other(score.server); // their game, and now their serve
matchReady();
G.phase = 'over'; G.overT = 1.8;
}
export function resolveInterlude(side) {
if (!G.interlude) return;
// THE MATCH HOOK: a settled point feeds the game score instead of ending the interlude — the
// interlude is permanent while a match runs (it is what keeps the serving tennis-like and the
// spawn director quiet). Regicide arrives here too, as a 'P' with the body already gone.
if (G.match) {
// a rally resolved at its DECIDED moment (the early fall) is spent: the ball that kept rolling
// for the picture still reaches its ordinary bounce-2 / rolled-out calls, and they land here.
// G.matchEarly exempts the deciding resolution ITSELF — the latch goes up before it runs.
if (G.match.decided && !G.matchEarly) { ball.active = false; return; }
// netplay (death match): this phone judged the live flight — the verdict crosses the wire
// before it is applied, exactly as pointTo does for classic. netJudging() is false while a
// remote verdict is being applied, which is what stops the echo.
if (netJudging()) netSendPoint(side, null);
// the empty-court check reads "no body = regicide" — meaningless in netplay, where the court
// NEVER carries a body (the friend is the living O) and every point would read as a game
if (!G.net && !zombies.some(z => z.variant === 'r') && !G.match.ending) return matchGameWon(null);
return matchPoint(side);
}
G.interlude = false;
zombieDuck(false); // duel over — bring the music back up
ball.active = false; G.phase = 'over'; G.overT = 2.2;
if (side === 'P') {
cheer(3, 0.7);
deliverTin(); // win the point, win the next tin
// ...and the trainer comes out. THE reason to want a duel rather than merely tolerate it — it is
// the only healing in the game, and blood is the only thing you cannot buy back otherwise. It
// gets its OWN popup (queued behind the weapon), with the red cross over it: folded in as a line
// of body text under a weapon icon it read as a footnote to the tin rather than as the prize.
if (G.hp < G.hpMax) {
G.hp = Math.min(G.hpMax, G.hp + TUNE.healPerDuel);
aside('ump_trainer', 0.2); // benched with the rest of Daniel's personality — see aside()
showModal({ title: 'WOUNDS TREATED', icon: 'x_cross', iconH: 52, color: UI.pick,
body: ['THE TRAINER PATCHED YOU UP', G.hp + ' OF ' + G.hpMax + ' BACK'] });
}
} else {
voice('game_opponent', 0.4); aww(0.85); // lost the sudden-death duel
score.games[1]++; G.runGames++; // it goes on the scoreboard
if (score.games[1] >= 6) { G.sets[1]++; score.games = [0, 0]; }
G.flash = { text: 'GAME. THEM.', t: 1.6 };
for (let k = 0; k < 5; k++) // and the dead lose their patience: an instant surge
spawnZombie((Math.random() * 2 - 1) * 5, -1.5 - Math.random() * 2, Math.random() < 0.5 ? 'a' : 'b');
}
if (G.afterDuel) { // a scheduled duel interrupted a game beat — hand it back now that it's settled
G.afterClear = G.afterDuel; G.afterDuel = null;
proceedAfterClear();
}
}
// ---- NETPLAY: 1v1 classic tennis against another phone. The wire, the protocol and the judging
// rule all live in src/net.js; ONLY the court ownership lives here, where the contract can be
// enforced — same shape as every mode above. It runs in G.mode 'tennis' with G.classic set (the
// caller does that via start('classic')), so the whole splatter schedule is structurally inert;
// the disarm lines below are the same belt the drill and the bench wear anyway.
export function startNetVs() {
if (G.net || G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench) return false;
G.net = { ready: false, dead: false, side: null, code: null, opp: null };
G.pendingHorde = false; G.hordeWarn = 0; G.bossDue = 0; G.duelDue = false;
G.changeover = false; G.clearing = false; G.afterClear = null;
G.weapon = 0; G.mgT = 0; endBurst(); G.boy = null;
zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0;
ball.active = false; setupPoint();
return true;
}
export function endNetVs() {
if (!G.net) return;
netClose();
G.net = null;
ball.active = false; setupPoint();
}
// THE BREATHER BEFORE THE WAVE. The horde used to begin on the same frame as its own announcement,
// so the flash was already gone by the time the crowd was on you — "the horde is not announced
// anymore". It is an ordinary 'over' beat (no serving, the world keeps walking, the RESUMING clock
// counts it down under the flash), and startHorde still owns the transition itself.
export function warnHorde() {
if (G.horde || G.interlude || G.bossFight || G.drill || G.match || G.bench || G.net) return false; // same court-ownership contract as the rest
G.hordeWarn = TUNE.hordeWarnT;
G.flash = { text: 'THEY KEEP COMING', t: TUNE.hordeWarnT };
ball.active = false; G.phase = 'over'; G.overT = TUNE.hordeWarnT;
return true;
}
// the swarm keeps coming: no serving, the racket auto-fires, the thumb drag steers you.
// Returns false when another mode owns the court — the caller re-arms the latch rather than
// silently eating the wave the set paid for.
export function startHorde() {
if (G.horde || G.interlude || G.bossFight || G.drill || G.match || G.bench || G.net) return false; // same contract as startDuel: one mode owns the court
G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked
const grow = 1 + (G.sets[0] + G.sets[1]) * TUNE.hordeGrow; // each set banked makes the next wave worse
// the gun grows WITH the wave: spawn rate and length both scale by grow, so a flat gun fell
// further behind on every set banked (the second horde was where every run ended)
G.horde = { t: TUNE.hordeLen * grow, spawnT: 0, mgT: 0, rate: TUNE.hordeRate * grow, mgRate: TUNE.mgRate * grow };
ball.active = false; G.phase = 'rally'; // ball gone; 'rally' lets the movement block steer P
P.pose = 'idle'; P.poseT = 0; P.swingT = -1; // clear the triggering shot's serve/swing pose
P.z = HOME.P.z; // brace at the baseline — the horde is a hold-the-line stand (finger steers x only)
aside('ump_spectators', 0.4); // a horde IS the crowd on the court. (The near-identical 'remain seated'
// that used to play right before it belongs to the transition, not here.)
G.flash = { text: 'THEY KEEP COMING', t: 1.2 }; // re-raised so the warning's line carries into the wave
return true;
}