// Splatter Tennis — boot, main loop, intro overlay, and the window.T test hooks.
// World units: meters. z axis = court depth, 0 at FAR baseline, 23.77 at NEAR baseline.
// Module map: config (tuning/constants) · state (mutable game state) · gameplay (logic/update)
// render (camera/drawing) · input (keys/touch) · audio (sfx/umpire) · panel (T tuning UI)
import { TUNE, COURTS, TOURS, KITS, SKINS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, LEAGUES, leagueOf, cupRoundsFor, cupFixture, cupRoundName, matchFoe, LADDER } from './config.js';
import { P, O, ball, score, G, setupPoint, sendBoy, selectCourt, selectTour, selectKit, selectSkin } from './state.js';
import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, startGore, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, dragServe, toggleTestMode } from './gameplay.js';
import { startDuel, resolveInterlude, startHorde, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall, startNetVs, endNetVs } from './beats.js';
import { netConnect, netHooks } from './net.js';
import { fitViewport, render, proj, RES, SPRV, LOAD, SPR, drawLook, endHit } from './render.js';
import { voice, ambient, menuMusic, menuDuck } from './audio.js';
import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer , spawnBarrel } from './zombies.js';
import { cv } from './dom.js';
import { haptic } from './haptics.js';
import * as stats from './stats.js';
import * as profile from './profile.js';
import './input.js';
let last = performance.now();
// T.pause(true) freezes the WORLD but keeps drawing, so a value changed in devtools shows up on the
// next frame without the scene moving on underneath you. Halting rAF instead (the old test recipe)
// freezes the picture too, which is useless for looking at a shot.
let paused = false;
// CALM STATES RUN SLOW. The battery bill is fill-rate × frame-rate, and two screens can sit on a
// phone for minutes while fully static: a SETTLED modal (the world is frozen behind it — only the
// blink still moves) and the death summary once its fade and gore have played out. 20-odd fps
// there is invisible and thirds the drain; live play stays at TUNE.fps, untouched — the 60 was a
// feel decision and this deliberately never applies to anything that moves.
const calmFps = () => G.modal && G.modal.in >= 1 ? 20
: G.mode === 'eaten' && G.eatenT > EATEN_RESTART + 1 ? 24 : 0;
let lowPowerApplied = false; // the last lowPower state fitViewport was run under (T-panel flips it live)
function frame(now) {
requestAnimationFrame(frame); // schedule FIRST: one bad frame must never kill the loop (a thrown error froze the game for good)
if ((TUNE.lowPower >= 0.5) !== lowPowerApplied) { lowPowerApplied = TUNE.lowPower >= 0.5; fitViewport(); }
const cap = calmFps() || TUNE.fps;
if (cap && now - last < 1000 / cap - 2) return; // frame cap (battery): skip RAF ticks until it's our beat
// THE MENU IS OPAQUE (#intro: fixed, inset 0, solid background). Painting the whole court at RES
// density behind it — for the minutes a phone idles on the splash and stats screens — was pure
// battery burn nobody could see. The world holds still too; nothing on it predates the menu.
if (menuUp()) { last = now; return; }
if (paused) { last = now; render(); return; }
let dt = Math.min(0.04, (now - last) / 1000); last = now;
// A chapter boss dies in slow motion. The clock that ENDS it runs at real speed, so the effect is
// always the same length in seconds however far the world has been slowed.
if (G.slowT > 0) { G.slowT -= dt; dt *= TUNE.bossSlowMo; }
update(dt); render();
}
// PORTRAIT ONLY, from 2026-08-20. The app enforces it where it can actually be enforced — the
// shell's Info.plist lists one supported orientation, so an iPhone running the store build simply
// never rotates. This line is the WEB half of the same decision and is best-effort by nature:
// Chrome/Android honours a lock once the page is installed or fullscreen, and iOS Safari has no
// Screen Orientation lock at all, so the browser build keeps doing whatever landscape does today
// (PROJECT.md OPEN 6 — landscape is untested, not a surface). Nothing downstream may assume it took.
try { screen.orientation?.lock?.('portrait')?.catch(() => {}); } catch {}
fitViewport();
// iOS PWA cold start. Launched from the homescreen, WebKit resolves the viewport AND every env()
// safe-area inset once, against a provisional geometry, and never revisits them until the geometry
// is "exercised" — which is why the game only filled the screen after a rotate and back, and why
// waiting for an event could never work: nothing fires. Toggling viewport-fit off and on is the
// known WebKit workaround: it forces that recalculation without the user rotating anything.
// Standalone only, because that is the only mode with the bug.
if (navigator.standalone || matchMedia('(display-mode: standalone)').matches) {
const meta = document.querySelector('meta[name=viewport]'), real = meta.content;
meta.content = real.replace('viewport-fit=cover', 'viewport-fit=auto');
requestAnimationFrame(() => { meta.content = real; requestAnimationFrame(fitViewport); });
}
setupPoint();
// warm both faces so the canvas never paints a system-serif fallback frame
if (document.fonts) for (const f of ['700 8px Silkscreen', '400 8px Silkscreen', '400 20px Bangers'])
document.fonts.load(f);
// The overlay is a four-stage machine: splash -> {setup, stats, ranks}. The title screen IS the
// menu — a separate menu stage behind it showed the same logo on the same backdrop and read as the
// title screen failing to advance. One overlay throughout, swapped by data-stage, so the court never
// flashes between screens; HIDDEN during play rather than removed, so a restart puts it back.
const intro = document.getElementById('intro');
const menuUp = () => !intro.hidden;
const remembered = k => { try { return localStorage.getItem(k); } catch { return null; } };
let picked = remembered('court') || COURTS[0].id;
let pickedTour = remembered('tour') || TOURS[0].id;
// every stage change is a gesture, so it is also a safe place to (re)start the theme. The setup
// screen syncs its picker rows on entry: the identity screen writes the same picked* state, and
// rows that only ever toggle on their OWN taps would keep showing the boot-time selection.
// EVERY SCREEN OPENS AT ITS TOP (2026-08-31, Oliver: scroll the stats to the bottom, swipe back,
// and the splash comes up scrolled with its buttons off screen). #intro is the one scroller the
// menus share, so its offset outlives the stage that earned it unless it is reset here — the one
// place every stage change goes through.
const go = stage => {
if (stage === 'setup') syncSetup();
if (stage === 'train') buildTrain();
intro.dataset.stage = stage;
intro.scrollTop = 0;
dealStage(stage);
menuMusic();
};
// THE ARCADE ENTRANCE, re-armed. The title screen deals its plaque and buttons in from the left
// every time you arrive at it — off a run, off the stats, off the boot. Removing the class and
// forcing a reflow before adding it back is the whole trick: an animation whose class never left
// cannot restart, and there is no "replay" switch in CSS.
function dealStage(stage) {
// the title screen waits for the boot: while the loading state is up there is nothing to deal
// (the element is looked up here, not read off the const below: go() can fire before that line
// has run, and a temporal-dead-zone throw in the menu funnel would take every screen with it)
if (stage === 'splash' && document.getElementById('splash').classList.contains('busy')) return;
intro.classList.remove('deal');
void intro.offsetWidth;
intro.classList.add('deal');
}
// --- MODE selection in TWO STEPS (config.MENU + config.MODES): three posters on top, and the
// picked one deals its own second step underneath — classic deals the difficulty chips, zombie
// tennis deals its two sub-mode cards, gore just is gore. `pickedMode` stays the RUN mode id
// (classic/zombie/cup/gore) so start(), stats and the summary never learn about the grouping.
let pickedMode = remembered('mode') || MODES[0].id;
let pickedDiff = remembered('diff') || 'medium';
const modeOf = id => MODES.find(x => x.id === id);
const topOf = m => MENU.find(t => t.mode === m || (t.kids && t.kids.includes(m))) || MENU[0];
const modeList = document.getElementById('modeList');
const modeSub = document.getElementById('modeSub');
// QUESTION SCREENS CARRY NO SELECTION (Oliver: a highlighted card that also navigates is "a
// checkbox and a button combined"). Every poster is full-bright and equal — all of them are live
// answers — and the only memory shown is the LAST tag, an indicator rather than a selection. The
// select-and-confirm border lives solely on the final screen, where SERVE UP actually confirms.
// `opts.line` is the ONE second line the wall still carries: the fixture, under the hero's name.
// The corner chips are gone (2026-08-31, Oliver: "sinnlos") — LAST told you what you played last
// time, which the wall does not need to remember for you, and 18 RUNGS was a number about a mode
// on a screen for choosing between modes.
const banner = (card, cls, opts = {}) => {
const b = document.createElement('button');
b.className = cls;
// JUST THE ART. The scrim used to ride in this background stack as a hard linear-gradient; the
// caption sits on a veil now (index.html, button.mode::after) that rises out of the bottom edge,
// and stacking the old gradient under it only doubled the darkening.
b.style.backgroundImage = `url(assets/${card.art}.png?v=${SPRV})`;
// THE POSTER IS ITS NAME (2026-08-31, the text reduction). The mode's one-line rule used to sit
// under every name on the wall — sixty words to make one choice. The rules live in the modal
// that opens with the mode; MODES.sub is still their source, it just no longer prints here.
b.innerHTML = `${card.name}${opts.line ? `${opts.line}` : ''}`
// the standing answer to "how do I advance", worn by the poster it is true of (config MODES)
+ (card.tag ? `${card.tag}` : '');
return b;
};
const pickMode = id => { pickedMode = id; try { localStorage.setItem('mode', id); } catch {} };
// TAP ADVANCES. Each screen's tap both answers and moves on — the wizard's whole promise is one
// decision per screen, and a NEXT button under a single row of choices is a second tap for nothing.
// (NETPLAY is the exception and has a screen of its own — see THE LOBBY below.)
function buildSub(top) {
modeSub.innerHTML = '';
const t = top || topOf(pickedMode);
document.getElementById('subTitle').textContent = t.diffs ? 'DIFFICULTY' : (t.name || '');
// THE DIFFICULTY IS A COUNT, not a paragraph (2026-08-31, the text reduction): the name, and
// three pips of which as many are lit as the chip is hard. Nothing else — the CPU explains
// itself in the first rally, and it always did.
if (t.diffs) DIFFS.forEach((d, i) => {
const b = document.createElement('button');
b.className = 'chip' + (i === DIFFS.length - 1 ? ' hot' : '');
// no LAST chip here either (Oliver): a question screen carries no memory of its own answer —
// the wizard's own rule, applied to the last screen that was still breaking it
b.innerHTML = `${d.name}`
+ [0, 1, 2].map(k => ``).join('') + '';
b.addEventListener('click', () => {
pickedDiff = d.id;
try { localStorage.setItem('diff', d.id); } catch {}
go('setup');
});
modeSub.appendChild(b);
});
}
// the top posters are rebuilt on every visit so the LAST tag follows what was actually played
function buildTops() {
modeList.innerHTML = '';
let shelf = modeList; // posters land in the section they belong to
for (const t of MENU) {
// A GROUP ROW OPENS A SECTION (2026-08-31, Oliver: the headings "sind sehr klein, vielleicht
// können wir daraus richtige sektionen machen"). The posters go INSIDE it — a heading that
// merely floats above a stack is a label, and the wall needs three rooms, not three labels.
if (t.group) {
shelf = document.createElement('section');
shelf.className = 'mgroup ' + (t.layout || '');
shelf.innerHTML = `
`
+ `${c.name}` // the sub only ever carries a LOCK now (syncSetup)
+ meter('SPEED', (c.bounceXZ - 0.62) / 0.26)
+ meter('BOUNCE', (c.bounceY - 0.44) / 0.28, 'b')
+ '';
// Tapping a tile SELECTS it and nothing more. It used to start the match on the spot, which gave
// you no way to look at the three of them and no way back out of the screen.
b.addEventListener('click', () => {
if (pickedMode === 'cup') return; // the cup is the league fixture — the league picks
if (!courtUnlocked(c.id)) return; // locked: the row itself says which league opens it
picked = c.id;
for (const el of courtList.children) el.classList.toggle('on', el === b);
});
courtList.appendChild(b);
}
// a remembered pick that is locked for THIS profile (fresh install, adopted mirror) falls back
// to the league's own court rather than serving up a court the player has not earned
if (!courtUnlocked(picked)) picked = leagueOf(profile.prof.lg).court;
// THE DRAW SCREEN IS GONE (2026-08-29, Oliver: "das ist doch jetzt eigentlich obsolet"). It was
// built for the 18-rung ladder, where four bands rolling out of a deep hat was a ceremony worth a
// screen; a league cup shuffles 2-3 faces the setup's league panel already shows face-up. The
// shuffle itself survives INSIDE the run (cupDraw rolls without replacement, the final settled)
// and each round still announces itself on the walk-on modal — only the screen is deleted. The
// cup's big button says ENTER THE CUP (syncSetup), which is all the ritual the moment needs.
// (It said ENTER THE DRAW until 2026-08-31 — a draw is a field of entrants, and nothing has been
// drawn since the rounds went deterministic. Oliver had to ask what it meant, which settled it.)
// The screen's mechanics live in git history (like the lob) if the ceremony is ever asked back.
// the portrait: every family's own head crop (assets/_heads.py), the club pro's included — the
// same file the in-play walk-on card uses, so the picker and the court show the same face
const foeArt = f => `assets/z_${f.fam}head.png?v=${SPRV}`;
// ---- THE TRAINING PICKER (Oliver, 2026-08-28: "ein Trainingsbereich — Auswahl des Gegners, ein
// Spiel, tap to replay, bis man keine Lust mehr hat"). The whole ladder face-up, one tile per rung,
// wearing the same z_*head crop the draw and the walk-on wear. Tap = start — the wizard's own
// tap-advances rule; the SERVE UP button routed here instead of starting (see cStart below).
let trainRung = 1;
const trainList = document.getElementById('trainList');
// rebuilt on entry (go('train')): you can only train who the club has let you meet — every rung
// up to the top of YOUR league's band. LADDER stays the cap for a maxed profile.
function buildTrain() {
trainList.innerHTML = '';
const top = Math.min(LADDER, leagueOf(profile.prof.lg).band[1]);
// THE REVANCHE (THE SEASON): the body that ended your last cup attempt is marked and sits FIRST
// in its own right — a practice list where every rung looks the same is a shelf, and this one
// room in the game has an obvious reason to exist the moment you have just been put out.
const out = profile.prof.out;
const rung = n => {
const f = matchFoe(n), b = document.createElement('button');
b.addEventListener('click', () => { trainRung = n; start('train'); });
return [f, b];
};
// THE REVANCHE IS A ROW, not a tile in the grid (the text reduction): it is one body out of
// eighteen and the only one with a reason, so it gets the width to say so — and says it in
// three words, because the screen it sits on is already called TRAINING.
if (out >= 1 && out <= top) {
const [f, b] = rung(out);
b.className = 'put';
b.innerHTML = `
`
+ `${won ? (at ? 'AT ' + at : 'WON') : '—'}` + glints(won);
row.appendChild(cell);
}
const champ = document.createElement('div');
champ.className = 'badge' + (profile.prof.ch ? '' : ' dark');
champ.innerHTML = `
`
+ `${profile.prof.ch ? 'CHAMPION' : '—'}` + glints(profile.prof.ch);
row.appendChild(champ);
const g = stats.read('all', undefined, stats.MODE_GORE),
z = stats.read('all', undefined, stats.MODE_MATCH),
c = stats.read('all', undefined, stats.MODE_CLASSIC);
// the ledger climbs: rows are [box id, [label, target, format]...]; every value starts at 0 and
// counts up through the same easing the bars sweep with
// THE CARD IS WHO YOU ARE, not what you have done (2026-08-31, the stats streamline): the
// lifetime ledger lives on BODY COUNT, which is the screen for it. What stays here is the
// CAREER — how far up the club you have got, and how far each cup let you.
for (const [boxId, rows] of [
['cdFigs1', [['LEAGUE', profile.prof.lg], ['CUPS WON', profile.prof.lg - 1 + profile.prof.ch],
['BEST CUP', profile.cupBest(profile.prof.lg)]]],
['cdFigs2', [['BODIES FELLED', g.kills + z.kills + c.kills],
['DEEPEST CLIMB', z.longest],
['RUNS', g.matches + z.matches + c.matches]]]]) {
const box = document.getElementById(boxId);
box.innerHTML = rows.map(([label]) => fig(label, '0')).join('');
[...box.querySelectorAll('.fig i')].forEach((cell, i) => {
const fmt = rows[i][2] || (v => v.toLocaleString('en-GB'));
countUp(v => cell.textContent = fmt(v), rows[i][1]);
});
}
refreshLook();
}
document.getElementById('plaque').addEventListener('click', () => { buildCard(); go('card'); });
document.getElementById('cdBack').addEventListener('click', () => go('splash'));
let playerReturn = null; // EDIT PLAYER borrows the identity screen and comes back to the card
document.getElementById('cdEdit').addEventListener('click', () => {
playerReturn = 'card'; buildPlayer(); go('player');
});
// --- THE PROMOTION CEREMONY (the leagues). beats.cupWon parks it in the profile module; the tap
// off the cup summary lands here (maybeRestart) before the title screen. The court tile IS the
// reward on screen; the name prompt, if the run also earned one, queues behind the ceremony.
let promoRun = null;
function buildPromo(pr, run) {
promoRun = run || null;
const lg = leagueOf(profile.prof.lg);
const c = COURTS.find(x => x.id === lg.court) || COURTS[0];
const champion = !!pr.champion;
document.getElementById('prTag').textContent = champion ? 'THE FINAL CUP IS YOURS' : 'THE CUP IS YOURS';
document.getElementById('prTitle').textContent = champion ? 'CHAMPION.' : 'PROMOTED.';
document.getElementById('prLeague').textContent = champion
? 'THE DEAD CLUB HAS NOBODY LEFT'
: `LEAGUE ${lg.id} OF ${LEAGUES.length} — ${lg.name}`;
document.getElementById('prCourt').src = `assets/${c.tile}.png?v=${SPRV}`;
document.getElementById('prCourtName').textContent = champion
? 'THE HONOURS BOARD REMEMBERS YOUR NAME'
: `NEW COURT UNLOCKED — ${c.name} · OPEN IN EVERY MODE`;
document.getElementById('prGo').textContent = champion ? 'TAKE A BOW' : 'WALK ON';
// ...and what the new league has you down for. The champion has nothing above him, so his beat
// is the title he now has to hold instead of a fixture he has to win.
const fx = cupFixture(profile.prof.lg, profile.prof.ch);
document.getElementById('prNext').innerHTML = champion
? `THE TITLE IS YOURS TO HOLDEVERY CUP FROM HERE IS A DEFENCE`
: `NEXT: ${fx.title}${fx.line}`;
}
// THE CUP BOARD (2026-08-31): a promotion is the row, and it posts itself. There is no name
// prompt in the way — you have had a name since the identity screen — and no personal-best gate:
// the server keeps one row per name per board and the league you just took either beats what
// stands there or does not (rank_key, server.py).
function postCup(pr) {
const took = pr.champion ? LEAGUES.length : pr.took;
if (!took || !pr.at || !stats.savedName()) return;
stats.submit(stats.savedName(), took, pr.at, stats.MODE_CUP, profile.prof.lg).catch(() => {});
}
document.getElementById('prGo').addEventListener('click', () => {
const run = promoRun; promoRun = null;
if (run && run.newKillBest && run.score > 0) askName(run);
else go('splash');
});
// Which RECORD BOOK a run mode writes to. The four run modes share three books — the arcade ladder
// and the tournament are both zombie tennis and are counted together (see the record-shape note in
// stats.endRun). Lived inline in three places before the world board grew tabs of its own.
const boardFor = m => m === 'zombie' || m === 'cup' || m === 'train' ? stats.MODE_MATCH
: m === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE;
// what the setup footer says you are about to play, and what it is worth beating
// The splash carries ONE number, on the STATS button. A boxed BEST RUN / WORLD RANK bar across the
// top came out of the mockup and went again: it sat on the best part of the art and said twice what
// the two buttons underneath already say. Re-read whenever the splash comes back up, because a run
// has just ended and the count has moved.
function refreshSplash() {
// THE TEXT REDUCTION (2026-08-31): the two secondary doors carry a NUMBER each instead of a
// description — what you have done, and your own best on the board they open. The board figure
// is read locally on purpose: the splash does not fetch the world board.
document.getElementById('mKills').textContent = stats.total().kills.toLocaleString('en-GB');
document.getElementById('mBest').textContent =
'BEST ' + stats.bestScore(boardFor(pickedMode)).toLocaleString('en-GB');
// THE PLAQUE — the meta identity, re-read whenever the splash comes back because a run has just
// moved it (same rule as the kill count above). One row now: crest, name and level, and the bar
// under them wearing the level it fills toward. The goal LINE is gone — the fixture button below
// says what to do, and it said it twice.
const pr = profile.progress(), lg = leagueOf(profile.prof.lg);
document.getElementById('plBadge').src = `assets/badge_l${lg.id}.png?v=${SPRV}`;
document.getElementById('plName').textContent = stats.savedName() || 'YOUR PLAYER';
document.getElementById('plLv').textContent = 'LEVEL ' + pr.level;
document.getElementById('plTo').textContent = pr.need ? pr.level + 1 : 'MAX';
document.getElementById('plFill').style.width = Math.round(pr.frac * 100) + '%';
// THE FRONT DOOR (THE SEASON): the fixture, in the same words the summary and the promotion use
// (config.cupFixture) — minus the round count, which lives in the tournament's own modal now.
const fx = cupFixture(profile.prof.lg, profile.prof.ch);
document.getElementById('msTitle').textContent = fx.title;
document.getElementById('msLine').textContent = fx.line;
}
// `modeId` is the mode to drop into. It defaults to what the setup screen picked — but the BACK
// ROOM passes null, because those entries bring their own mode and a remembered ZOMBIE TENNIS was
// taking the court before startDrill/startBench/toggleTestMode could, so every one of them silently
// refused. The sprite bench found this the first time it was opened.
function start(modeId = pickedMode) {
if (!menuUp()) return;
selectTour(pickedTour);
selectKit(pickedKit); selectSkin(pickedSkin);
// THE LEAGUES: the CUP is the league fixture and plays the league's own court; every other
// mode — the climb included, it is an exhibition — plays your pick (among the unlocked).
selectCourt(modeId === 'cup' ? leagueOf(profile.prof.lg).court : picked);
menuDuck(true); // the theme belongs to the menus — hand the court over to the ambience
intro.hidden = true; ambient(); voice('quiet_please'); // low atmosphere bed starts with the first gesture
// ...and the mode the setup screen picked. THREE LINES, because that is genuinely all the modes
// differ by at this point: gore is the game as it always was, classic is the same tennis with the
// turn switched off, and zombie tennis is the match taking the court. Everything else about a mode
// lives in the code that reads G.classic / G.match, never here.
quitChip.hidden = false; // the door out rides every run (see the quit block below)
G.classic = modeId === 'classic';
// the difficulty rides on G like G.classic does, so a mid-run restart keeps the CPU you chose
G.diff = G.classic ? (DIFFS.find(d => d.id === pickedDiff) || DIFFS[1]) : null;
if (modeId === 'gore') startGore(); // no tennis prologue: the mode you picked is the mode you get
if (modeId === 'zombie') { intoSplatter(); startMatch(); }
// the cup rolls its own draw per round (cupDraw, without replacement) — the bracket screen
// that used to settle it on-screen is gone (2026-08-29, see the foeArt note above).
if (modeId === 'cup') { intoSplatter(); startMatch(true); } // cupDraw shuffles per round
// training: the picked rung rides the match machinery with the ladder parked (beats.startMatch)
if (modeId === 'train') { intoSplatter(); startMatch(false, null, trainRung); }
}
// the stats screen opens on the mode you last PICKED to play — that is the one whose numbers you
// came to look at, and it saves a tap on the way in every single time
const openStats = () => {
stats.drawStats('all', SPRV); go('stats');
};
// entering a question screen rebuilds it, so the LAST tag always names the run actually played —
// and the FIRST visit ever detours through the identity screen (the leagues): the club wants a
// name before it deals a match. ENTER THE CLUB lands on the mode screen, so the detour costs the
// question it interrupted nothing.
const goMode = () => {
if (!profile.prof.setup) { buildPlayer(); go('player'); return; }
buildTops(); go('mode');
};
// the fixture button IS the tournament — it skips the setup screen on purpose: the cup has no
// court to pick (it plays the league's own) and no opponent to choose, so that screen was a
// confirmation page with nothing on it to confirm
document.getElementById('mStart').addEventListener('click', () => {
if (!profile.prof.setup) { buildPlayer(); go('player'); return; } // the club wants a name first
pickMode('cup'); start('cup');
});
document.getElementById('mOther').addEventListener('click', goMode);
document.getElementById('mStats').addEventListener('click', openStats);
// the world board opens on the mode you last PICKED, like the stats screen does — that is the one
// whose names you came to look at
let rkMode = stats.MODE_GORE;
const openRanks = () => {
rkMode = boardFor(pickedMode);
for (const el of document.getElementById('rkModes').children) el.classList.toggle('on', el.dataset.m === rkMode);
go('ranks'); stats.drawRanks(null, rkMode);
};
document.getElementById('mRanks').addEventListener('click', openRanks);
document.getElementById('rkModes').addEventListener('click', e => {
const b = e.target.closest('button'); if (!b) return;
rkMode = b.dataset.m;
for (const el of e.currentTarget.children) el.classList.toggle('on', el === b);
stats.drawRanks(null, rkMode);
});
// --- THE NAME PROMPT. It appears on exactly one occasion: a run that beat your own best on ITS
// OWN board (every mode has one now), on the tap that takes you off the death screen. Anything
// more often is a form standing between you and the next match.
const nbName = document.getElementById('nbName'), nbMsg = document.getElementById('nbMsg');
let nbRun = null;
function askName(run) {
nbRun = run;
const B = stats.BOARDS[run.mode] || stats.BOARDS[stats.MODE_GORE];
// the prompt names the BOARD's number, not the gore mode's — a tournament run that said
// "5 KILLS" would be describing something nobody was playing for
document.getElementById('nbScore').innerHTML =
`${run.score.toLocaleString('en-GB')} ${B.unit} · ${run.sub} ${B.subUnit}`;
nbName.value = stats.savedName();
nbMsg.textContent = 'PUT IT ON THE WORLD BOARD.';
document.getElementById('nbSend').disabled = false;
go('newbest');
// focus only where a keyboard is already there. On a phone this would throw the soft keyboard up
// over the score you just earned, before you have read it.
if (!matchMedia('(pointer: coarse)').matches) nbName.focus();
}
document.getElementById('nbSkip').addEventListener('click', () => { nbRun = null; go('splash'); });
document.getElementById('nbSend').addEventListener('click', async () => {
const name = stats.tidyName(nbName.value);
if (!name) { nbMsg.textContent = 'A NAME FIRST.'; nbName.focus(); return; }
nbName.value = name; // show what is actually going on the board
if (!nbRun) return go('splash');
const btn = document.getElementById('nbSend');
btn.disabled = true; nbMsg.textContent = 'SENDING…'; // the double-tap posts twice otherwise
try {
const r = await stats.submit(name, nbRun.score, nbRun.sub, nbRun.mode, profile.prof.lg);
rkMode = nbRun.mode; // ...and the board that opens is the one you just wrote your name on
for (const el of document.getElementById('rkModes').children)
el.classList.toggle('on', el.dataset.m === rkMode);
nbRun = null;
go('ranks');
stats.drawRanks(r.scores, rkMode); // the board came back with the reply — no second round trip
} catch (e) {
btn.disabled = false;
nbMsg.textContent = e.taken ? 'THAT NAME IS SPOKEN FOR. PICK ANOTHER.'
: 'THE BOARD DID NOT ANSWER. TRY AGAIN?';
if (e.taken) { nbName.select(); nbName.focus(); }
}
});
nbName.addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('nbSend').click(); });
// () => start(), NEVER `start` — addEventListener hands the listener a MouseEvent as its first
// argument, so `start(modeId = pickedMode)` never saw its default and modeId was an EVENT OBJECT.
// `G.classic = modeId === 'classic'` was therefore always false and the zombie branch never fired:
// SERVE UP dropped you into the gore mode whatever the setup screen said, and picking CLASSIC or
// ZOMBIE TENNIS did nothing at all. A default parameter only applies to `undefined`, and a click
// handler always passes something.
// SERVE UP starts the run — except in the cup, where the draw has to be made first. That screen
// carries its own start button, so this stays the one place a run begins from the setup screen.
document.getElementById('cStart').addEventListener('click', () => {
if (pickedMode === 'train') { go('train'); return; } // the picker starts it — tap advances
start();
});
for (const id of ['mBack', 'stBack', 'rkBack', 'scBack'])
document.getElementById(id).addEventListener('click', () => go('splash'));
// the wizard walks back the way it came: who-and-where returns to the mode's own second step when
// it has one, the second step returns to the posters
document.getElementById('subBack').addEventListener('click', goMode);
document.getElementById('sBack').addEventListener('click', () => {
const t = topOf(pickedMode);
if (t.diffs || t.kids) { buildSub(); go('modesub'); }
else goMode();
});
document.getElementById('bsBack').addEventListener('click', () => go('secret'));
// SWIPE TO GO BACK — RIGHTWARD, the way iOS's own pop gesture goes (Oliver, 2026-08-20: *"that's
// the iOS way"*). Every screen but the splash wears exactly one chevron in its header (.hdr in
// index.html) and that button ALREADY knows where back is for its own screen — the wizard's shape
// lives in the handlers above and nowhere else. So the gesture finds the one visible chevron and
// clicks it, rather than keeping a second map that would drift the first time a stage is added.
// A screen with no chevron (splash, newbest) has nowhere to go and the swipe is simply ignored.
// Touch only: a trackpad two-finger scroll is not a swipe and must not navigate.
const SWIPE_MIN = 60; // px of travel before it counts — a tap that slides is not a swipe
let swipe = null;
intro.addEventListener('touchstart', e => {
swipe = e.touches.length === 1 ? { x: e.touches[0].clientX, y: e.touches[0].clientY } : null;
}, { passive: true });
intro.addEventListener('touchend', e => {
const from = swipe; swipe = null;
if (!from || e.touches.length) return;
const t = e.changedTouches[0];
const dx = t.clientX - from.x, dy = t.clientY - from.y;
// rightward, and clearly horizontal: the stats and board screens scroll vertically under the finger
if (dx < SWIPE_MIN || Math.abs(dy) > Math.abs(dx) * 0.7) return;
const back = [...intro.querySelectorAll('.chev')].find(b => b.offsetParent); // the one on screen
if (!back) return;
// NOT passive, and this is why: a swipe that starts and ends inside one of the big poster cards
// still produces that card's click, which would answer the screen forward in the same frame the
// chevron walked it back. preventDefault on touchend cancels the compatibility mouse events, so
// the gesture means one thing only.
e.preventDefault();
haptic('select');
back.click();
});
// ---- THE BACK ROOM: five taps in the TOP-LEFT CORNER of the splash (#hot, an invisible box in
// index.html), and nothing on the screen says so. It replaces the triple-tap on the in-play
// scoreboard, which sat on top of the miss-log tap and which the testteam kept opening by accident.
// The corner rather than the logo: on the way to PLAY MATCH your thumb crosses the middle of the
// splash all the time, and a hidden door you can open by accident is just a bug with a secret.
let logoTaps = [];
document.getElementById('hot').addEventListener('pointerdown', () => {
const now = performance.now();
logoTaps = logoTaps.filter(t => now - t < 2000);
logoTaps.push(now);
if (logoTaps.length >= 5) { logoTaps = []; go('secret'); }
});
// Both entries drop you into a REAL run rather than a special mode — same update loop, same
// scoring, same death — which is the only way a test tells you anything about the game.
// This is laserScene's trick: force the turn and then step the transition out in one go, because
// the cinematic is ten seconds you did not come here to watch.
function intoSplatter() {
startGore(); // the direct door. It used to force the turn and then step the whole 8-second
// cinematic out in 900 hidden updates — every sound it makes fired in one frame.
G.modal = null; G.horde = null; G.hordeWarn = 0; G.interlude = false; G.changeover = 0; G.clearing = false;
G.mgT = 0; G.gunId = null; G.beam = null; G.hp = G.hpMax = TUNE.playerHp;
zombies.length = 0; shots.length = 0; pellets.length = 0;
score.server = 'P'; setupPoint();
}
netHooks.intoSplatter = intoSplatter; // netplay's death match enters splatter through the same hack
document.getElementById('scWeapons').addEventListener('click', () => {
start(null); intoSplatter(); toggleTestMode();
});
document.getElementById('scBosses').addEventListener('click', () => go('bosses'));
// THE DRILL, from the back room — the cognitive mode's feel-test. Like the two above it drops
// into a real run first (same loop, same contact rules) and then hands the court to startDrill,
// which refuses if anything else still owns it.
document.getElementById('scDrill').addEventListener('click', () => {
start(null); intoSplatter();
startDrill();
});
document.getElementById('scMatch').addEventListener('click', () => {
start(null); intoSplatter();
startMatch();
});
// (netplay has no back-room button: the VS A FRIEND poster on the mode screen IS the door, and
// #net / #net-CODE below covers sending a phone straight in from a URL)
// THE SPRITE BENCH — the art test. Same "drop into a real run first" trick as its neighbours, so the
// body is drawn by the real renderer at the real size, on the real court, under the real palette.
// Judging a walk cycle anywhere else is judging a different picture.
document.getElementById('scBench').addEventListener('click', () => {
start(null); intoSplatter();
startBench();
});
// The list is built by ASKING bossArt which design each chapter gets, so it can never drift from
// what the run actually spawns — including the champion sitting on chapter 10 rather than 7.
const bossList = document.getElementById('bossList');
for (let ch = 1; ch <= 10; ch++) {
const art = bossArt(ch), b = document.createElement('button');
b.innerHTML = `
`
+ `${art.name}CHAPTER ${ch} · GAME ${ch * TUNE.bossEvery}`;
b.addEventListener('click', () => {
start(null); intoSplatter();
G.runGames = ch * TUNE.bossEvery; // the roster, the HUD and the spawn ramp all read this one
G.bossDue = 0; // ...and the schedule must not queue a SECOND fight behind it
for (const id of Object.keys(WEAPONS)) grantWeapon(id, TUNE.wCap); // full arsenal, every tin
G.weapon = 0;
startBossFight(ch);
});
bossList.appendChild(b);
}
// the three period tabs are one listener on the strip — the button carries the period in data-p
// the two tab strips are one listener each — the button carries its value in a data attribute
refreshSplash();
// the profile mirror: adopt a server copy that is ahead (a wiped phone, a second device), then
// let the plaque show whatever came back. The BOOT GATE holds the splash locked (skeleton
// plaque, no buttons, no keys) until this settles — capped at 4 s, because a dead network must
// never park the player on the title (the sprite loader's own rule).
let profileDone = false;
profile.pull().then(() => { profileDone = true; refreshSplash(); });
setTimeout(() => { profileDone = true; }, 4000);
// THE BOOT GATE. render.js fires all ~230 sprite requests at import; this only watches them and
// keeps the splash's buttons out of reach until the last one has landed and both faces are ready.
// Without it a cold start could open the stats screen — which hangs