Files
splatter-tennis/src/beats.js
T

1093 lines
69 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, leagueOf, cupRoundsFor, cupFixture, foeLevel } from './config.js';
import { endRun, MODE_MATCH } from './stats.js';
import { award, prof, save as profSave, push as profPush, setPromo, levelAt, recordCup, recordCupWin } from './profile.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 { recordEvent } from './recorder.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.serveNum = 1; // ...on a FIRST serve. A duel now faults (gameplay's onBounce), and
// nothing else resets it here — a fault in the last duel would have
// armed second-serve in the next one, which is the death loop this
// file already carries a note about, one mode over.
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'],
// both stakes named (the old card only sold the win): a lost duel is a real cost
rules: ['WIN IT AND THEY HAND YOU A WEAPON',
{ lead: 'LOSE IT AND FIVE MORE CLIMB THE FENCE', hot: true }] });
} 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).
// `train` (2026-08-28) is a RUNG NUMBER and turns the run into the TRAINING COURT: that one
// opponent, one game at a time, nothing banked. The ladder is parked — d.opp never advances, a
// felled body walks straight back on, and bleeding out costs a modal instead of the run. All of
// it rides the ordinary match machinery behind the d.train flag; see matchGameWon / matchPoint.
// `draw` is DEAD weight kept only for call-site arity — the cup runs easy to hard now
// (cupRoundsFor is deterministic, 2026-08-29) and nothing reads it.
export function startMatch(cup = false, draw = null, train = 0) {
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return false;
// THE LEAGUES: the profile's league is the world (band, pace tier, court — see PROJECT.md).
// Stamped onto G so gameplay's leaguePace() and the dials below never read the profile module.
// Netplay stays a league-1 world. Training still stamps league 1 because it promotes nobody,
// but its selected opponent supplies the real territory pace through gameplay.leaguePace.
G.league = G.net || train ? 1 : prof.lg;
// THE CLIMB: the arcade opens at the very BOTTOM of the ladder and runs the whole earned club,
// clamping at your league's top (matchGameWon) — the marathon whose length the promotions buy.
// The tempo shifts per band border (gameplay.leaguePace reads the rung's own league).
G.match = { opp: train || 1, ending: false, train: !!train, felled: 0 };
// 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.cupRoundsFor.
if (cup) { G.match.cup = { round: 0, g: 0, rounds: cupRoundsFor(G.league) }; 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];
// The mode's rules modal fires BEFORE matchOpponent, so it lands FIRST and the opponent's own
// walk-on modal queues behind it (showModal's modalQ keeps call order) — rules, then the body.
if (train && !G.seen.train) {
G.seen.train = true;
showModal({ title: 'THE TRAINING COURT', color: UI.hot,
body: ['ONE BODY, ONE GAME AT A TIME',
'FELL IT — OR BLEED OUT — AND IT COMES BACK', 'NO RUN IS BANKED — REPS PAY QUARTER XP'],
rules: ['FELL IT OR BLEED OUT — IT COMES BACK', 'NOTHING IS BANKED · REPS PAY ¼ XP'] });
} else if (cup && !G.seen.cup) {
G.seen.cup = true;
showModal({ title: 'THE TOURNAMENT', color: UI.hot, strap: cupFixture(G.league, prof.ch).sub,
// plain words (Oliver: "dead hierarchy" said nothing) — what you play, what winning buys
body: ['YOUR LEAGUE, ROUND BY ROUND — WIN IT ALL, MOVE UP A LEAGUE',
'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'],
rules: ['WIN IT ALL — MOVE UP A LEAGUE',
{ lead: 'POINTS DRAW BLOOD', hot: true, pips: TUNE.matchPlayerHp }] });
} else if (!cup && !train && !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. The card wears the mode's
// OWN name: the solo arcade is THE CLIMB now — its card still saying DEATH MATCH was a
// leftover of the era when it was one (2026-08-29 copy audit). Netplay keeps the old title,
// because there it is literally the mode.
showModal(G.net
? { title: 'DEATH MATCH', color: UI.hot,
body: ['WIN THE POINT — WOUND YOUR FRIEND',
'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'],
rules: ['WIN THE POINT — WOUND YOUR FRIEND',
{ lead: 'POINTS DRAW BLOOD', hot: true, pips: TUNE.matchPlayerHp }] }
: { title: 'THE CLIMB', color: UI.hot,
body: ['THE WHOLE CLUB, BOTTOM UP — ONE GAME PER BODY',
'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'],
rules: ['ONE GAME PER BODY — HOW FAR CAN YOU GET?',
{ lead: 'POINTS DRAW BLOOD', hot: true, pips: TUNE.matchPlayerHp }] });
}
recordEvent('run_start');
matchOpponent();
matchReady();
zombieDuck(true);
voice('play_resume', 0.2);
return true;
}
// The round's opponent. No draw and no roll any more: the rounds ARE the band, ascending, so
// this simply reads the round's one position off (cupRoundsFor carries the order). d.opp carries
// it so matchFoe, the ladder speed dials and the felled arithmetic stay cup-unaware.
function cupDraw() {
const cup = G.match.cup;
G.match.opp = cup.rung = cup.rounds[cup.round].band[0];
}
// 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
// ...and THE READ's one-ball states die with the point: a lean only lasts the flight it answered
if (G.match) G.match.surprised = G.match.expected = false;
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) placeReceiver(rz);
}
// WHERE A RETURNER ACTUALLY STANDS. The tennis mark is 1.3 m off the centre line, and from there he
// bisects nothing: the T is one step away and the corner of the box is four. Measured headless, a
// flush serve with full sway into that corner was returned 0 times out of 20 at EVERY rung of the
// ladder — Oliver: "the first ten zombies can be killed the moment you serve". He now stands out on
// the bisector of the two serves he has to cover, which is what a real receiver does, and O takes
// the same mark so the picture and the ballistics still agree.
// Split out of matchReady because a SECOND SERVE needs the same mark without matchReady's rest:
// that call resets G.serveNum, which is the one thing a fault has just spent (see fault()).
export function placeReceiver(rz = zombies.find(z => z.variant === 'r')) {
if (!rz) return;
if (score.server === 'P') O.x = Math.sign(O.x || 1) * TUNE.matchRecvX;
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.winT > 0 && (d.winT -= dt) <= 0) { d.winT = 0; return netMatchDone(); }
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 IS A MODAL NOW, not a timed card (2026-08-28, Oliver: "als richtiges Modal, das
// man wegtappen muss — you can actually read it without time pressure, and tap it away fast when
// you have read it a hundred times"). The walk-on is a guaranteed-calm court (the mop beat has
// just ended, the intro bar is armed), so the popup policy allows it; update()'s freeze holds the
// 1.6 s intro beat until the tap, and the head wears the same z_<fam>head crop the card wore.
// In the cup the round names itself on the second line.
const cup = G.match.cup;
// THE WALK-ON wears its own zoned layout (render.drawFoeModal — Oliver: the generic line
// stack was "a wall of text"). `foe` drives the drawing; title/body still carry the same
// words for the suite and any legacy reader. The level pair is also the cup's readiness measure:
// gameplay.peerK turns closing this gap into extra legs and timing, without weakening the body.
const sub = cup ? [cup.rounds[cup.round].name + (cup.rounds[cup.round].need > 1
? ' · FIRST TO ' + cup.rounds[cup.round].need + ' GAMES' : '')]
// the top of the band walking BACK on is the arcade's natural "what now?" moment — the
// intro answers it in place (THE LEAGUES: the cup is the only promotion)
: G.match.again ? ['NOBODY STRONGER IS LEFT', 'THE CUP IS THE WAY UP'] : [];
const lvl = foeLevel(G.match.opp), mine = levelAt(prof.xp);
const gap = lvl - mine;
const peer = gap > 5 ? gap + ' LEVELS ABOVE YOU' : gap > 0 ? 'ALMOST EVEN'
: gap === 0 ? 'EVENLY MATCHED' : 'YOU HAVE THE EDGE';
showModal({ title: zb.foe.name, color: UI.hot,
body: [zb.foe.line, 'LEVEL ' + lvl + ' — YOURS: ' + mine].concat(sub),
foe: { head: 'z_' + zb.fam + 'head', name: zb.foe.name, line: zb.foe.line,
lvl, mine, peer, sub } });
// ...and a fresh body has read NOTHING yet: the habit tracker starts clean (see THE READ, config)
G.match.laneRun = 0; G.match.lastLane = undefined; G.match.surprised = G.match.expected = false;
// 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);
recordEvent('opponent', { traits: { ...zb.foe } });
G.interlude = { introT: 1.6 }; // the duel machinery IS the match machinery
}
export function endMatch() {
if (!G.match) return;
recordEvent('run_end', { outcome: 'ended' });
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 && d.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) {
// THE TRAINING COURT HAS NO DEATH IN IT. Bleeding out costs a modal and a fresh pool — the
// point still goes to the body (advancePoint below), so a game you were losing stays lost.
if (d.train) {
G.hp = G.hpMax;
showModal({ title: 'IT BLED YOU OUT', color: UI.bad,
body: ['THE TRAINER MOPS YOU UP — GO AGAIN', 'NOTHING IS BANKED HERE'] });
} else {
// 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]; }
if (!d.train) G.runGames++; // the ladder metric — training banks nothing
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). Training parks the ladder the same
// way: the rung you picked is the rung that walks back on, as many times as you want it.
if (!G.net && !d.train) {
d.felled = (d.felled || 0) + 1; // bodies actually felled (the board's number)
// ...and the ladder climbs only inside the league's band: past its top rung the league has
// nobody stronger, so the top body walks back on (THE LEAGUES — the cup is the way up).
// `d.again` marks exactly that moment — the felled body WAS the top — so its walk-on modal
// can answer the "what now?" the repeat would otherwise pose (see matchOpponent).
const top = leagueOf(G.league).band[1];
d.again = d.opp >= top;
d.opp = Math.min(d.opp + 1, top);
}
// 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.
// NETPLAY DEATH MATCH: A GAME IS NOT A DEATH, AND ONLY BLOOD IS (Oliver, 2026-08-22: "each player
// has 8 blood points but the player explodes for the winner already at 0:40 — the winner sees the
// zombie explode but the player on the other side is not dead yet. this needs to be aligned").
// Two clocks were running: a GAME is four points, a life is eight, so the body burst on this
// phone while the friend was still standing on theirs with half a pool left. The burst moves to
// the only event both phones agree is death — their blood running out, said out loud over the
// wire (netSendDead -> netMatchWon). A won game keeps everything it is worth: the flash, the
// serve, and the heal it pays. Solo is untouched — there the body IS the scoreboard.
if (G.net) {
matchHealBeat();
d.ending = false; // nothing is walking on: play resumes here
matchReady();
G.interlude = { introT: 1.2 }; // a breath before the next serve, as ever
G.phase = 'over'; G.overT = 2.2;
return;
}
// Everything past the burst waits for it — the heal card, the boy, the walk-on clock.
d.afterFall = () => {
// training resets the blood WITHOUT the WOUNDS TREATED ceremony — every game starts whole,
// and a popup between every single rep is exactly the tap-tax the mode exists to avoid
if (d.train) G.hp = G.hpMax; else 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)
award('round');
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);
award('cup');
// THE PROMOTION (the leagues): the cup of your CURRENT league is the gate up. Winning it moves
// the profile, unlocks the next league's court for every mode, and parks a ceremony that
// main.maybeRestart plays once the summary is tapped away. Winning league 5 crowns the profile
// instead (prof.ch) — the summary below already says CHAMPION OF THE DEAD CLUB.
if (!d.train && G.league === prof.lg) {
// THE INSCRIPTION (the cup board): the level you were when you took it, written before the
// promotion moves anything — it is the level that WON the cup, not the one you walk on with.
const at = levelAt(prof.xp);
recordCupWin(G.league, at);
if (prof.lg < 5) { prof.lg++; setPromo({ to: prof.lg, took: G.league, at }); }
else if (!prof.ch) { prof.ch = 1; setPromo({ champion: true, took: G.league, at }); }
profSave(); profPush();
}
if (!d.train) recordCup(G.league, d.cup.rounds.length); // the full record: you took the final
const games = score.games[0] + score.games[1];
const b = endRun(d.cup.rounds.length, games, G.splatT, false, MODE_MATCH);
G.best = b; G.newBest = b.newBest;
d.felled = d.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.
// THE FRIEND'S BLOOD RAN OUT — the one event that ends a death match, and now the only one that
// bursts him. It arrives as a message from their phone (they bookkeep their own hp), which is why
// this is the honest place for the burst: both sides are agreeing about the same death.
export function netMatchWon() {
if (!G.match || G.match.winT > 0) return; // already ending
const d = G.match;
d.ending = false; d.mopT = 0;
G.matchFall = null;
ball.active = false; G.flash = null; G.modal = null; G.card = null;
explode(O.x, O.z); G.hideO = true;
cheer(4, 1); voice('unbelievable', 0.4);
// ...AND THE SUMMARY WAITS FOR THE GORE. update() freezes the whole world on G.done — no
// particles, no parts — so setting it on the same frame as the burst would hang a still frame of
// an explosion behind the trophy screen. The beat is ticked in tickMatchFall, like every other
// beat in this file that has to outlive the frame that started it.
d.winT = TUNE.netWinBurst;
G.interlude = { introT: TUNE.netWinBurst + 1 }; // nothing gets served over a body coming apart
G.phase = 'over'; G.overT = TUNE.netWinBurst + 1;
}
function netMatchDone() {
const d = G.match;
G.interlude = false;
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, reason = 'unspecified') {
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; }
if (!G.match.ending) recordEvent('point', { winner: side, reason });
// 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);
award('duelWin');
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;
}