Files
splatter-tennis/src/main.js
T
wuwiandClaude Fable 5 e96b4374d8 BODY COUNT loses its nine states, the board loses a mode
Three mode tabs times three period tabs, and the same three boxes meant
something different in every one — Oliver had no idea what he was looking at,
which is the correct reaction to a screen with nine states. It is one screen
now: bodies felled, best gore run, deepest climb, runs. Never anything else.

MULTI-KILLS retired (a vanity number on no board) and DEATHS with it (every run
ends in one, so it was RUNS again). The period filter went too: LAST RUN is the
summary you just tapped away, and 7 DAYS is a fitness metric in a permadeath
game. The card stops repeating the ledger and keeps the CAREER instead —
league, cups won, best cup.

The world board drops CLASSIC: that mode is out of competition by its own
room's rule now, so ranking it contradicted the wall. A classic best no longer
opens the name prompt either — it would have had nowhere to go.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 15:39:27 +02:00

1197 lines
73 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 = `<span class="cb"><b>${card.name}</b>${opts.line ? `<i>${opts.line}</i>` : ''}</span>`
// the standing answer to "how do I advance", worn by the poster it is true of (config MODES)
+ (card.tag ? `<u class="way">${card.tag}</u>` : '');
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 = `<b>${d.name}</b><span class="pips">`
+ [0, 1, 2].map(k => `<i${k <= i ? ' class="on"' : ''}></i>`).join('') + '</span>';
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 = `<header><b>${t.group}</b></header><div class="shelf"></div>`;
modeList.appendChild(shelf);
shelf = shelf.querySelector('.shelf'); // the posters live in the row, not the section
continue;
}
const fx = cupFixture(profile.prof.lg, profile.prof.ch);
// the fixture is the hero and carries the only second line on the wall
const b = banner(t.mode ? modeOf(t.mode) : t, 'mode',
{ line: t.mode === 'cup' ? 'LEAGUE ' + fx.lg + ' · ' + fx.line : null });
b.addEventListener('click', () => {
// the netplay poster opens THE LOBBY and never touches pickedMode — it is not a run mode
// the stats screen or the summary could name, and the lobby starts the game itself
if (t.net) { vsOpen(); return; }
// a group remembers which child you were on; entering it fresh lands on its first card
pickMode(t.mode || (t.kids.includes(pickedMode) ? pickedMode : t.kids[0]));
buildSub();
go(t.diffs || t.kids ? 'modesub' : 'setup');
});
shelf.appendChild(b);
}
}
buildTops();
buildSub();
// ---- THE LOBBY. Oliver, 2026-08-15: *"multiplayer should be easier to setup — a share with link
// next to the code; entering the code is done in some plain js confirm dialog thingy; somewhere
// when hosting or also when joining the player should name themselves."* All three are one screen.
//
// WHY IT IS A MENU SCREEN AND NOT A COURT. Hosting used to drop you straight onto an empty court
// and show the code in a canvas modal — which cannot carry a SHARE button, cannot be typed into,
// and turns a mistyped code into a CONNECTION LOST card over a match that never started. The
// socket is now opened from the menu and the game stays parked behind the overlay until the room
// actually fills: `netHooks.paired` is what takes the court, one frame before netStart writes to
// it. Everything else about the protocol is untouched.
//
// THE NAME IS THE WORLD BOARD'S NAME (stats.saveName). One name per player, wherever it shows.
const vsPick = document.getElementById('vsPick'), vsWait = document.getElementById('vsWait');
const vsName = document.getElementById('vsName'), vsCode = document.getElementById('vsCode');
const vsSay = (id, text) => { document.getElementById(id).textContent = text; };
function vsOpen(code = '') {
vsName.value = stats.savedName();
vsCode.value = code;
vsPick.hidden = false; vsWait.hidden = true;
vsSay('vsErr', '');
go('vs');
}
// join === null hosts; `death` is the host's one decision and rides the room to both phones
function vsGo(join, death = false) {
stats.saveName(vsName.value); // whatever is in the field is who the other phone sees
const code = join ? stats.tidyName(vsCode.value).replace(/[^A-Z0-9]/g, '') : null;
if (join && code.length < 4) { vsSay('vsErr', 'THE CODE IS FOUR LETTERS'); return; }
if (!startNetVs()) return; // the court-ownership contract, same as every other mode
netConnect(code, death, picked); // the host's court rides the room (server.py)
vsPick.hidden = true; vsWait.hidden = false;
vsSay('vsWHdr', join ? 'JOINING' : 'ROOM CODE');
vsSay('vsBig', join ? code : '····');
vsSay('vsMsg', join ? 'KNOCKING ON THEIR DOOR' : 'WAITING FOR THEM TO JOIN');
document.getElementById('vsShare').hidden = !!join; // nothing to share on the way in
}
document.getElementById('vsHostC').addEventListener('click', () => vsGo(null));
document.getElementById('vsHostD').addEventListener('click', () => vsGo(null, true));
document.getElementById('vsJoin').addEventListener('click', () => vsGo(true));
vsCode.addEventListener('keydown', e => { if (e.key === 'Enter') vsGo(true); });
// SHARE, then COPY. navigator.share is the phone's own sheet (WhatsApp, Signal, wherever the
// friend actually is) and is exactly the "easier setup" — the clipboard is the desktop fallback,
// and the code stays on screen for the two of them in the same room reading it aloud.
// THE MESSAGE IS THE CODE, and deliberately carries no link. A link only opens the INSTALLED app
// on Android (the manifest's scope covers the site, so its WebAPK claims these URLs); an iOS
// home-screen app can claim nothing, so on an iPhone it always opened Safari instead — a second
// copy of the game, with its own storage and none of the name they picked in the app. Until this
// is a real app with universal links, four letters are the invitation. (`#net-ABCD` still works as
// a door — it is what a deep link will carry the day there is one — nothing just sends it now.)
document.getElementById('vsShare').addEventListener('click', async () => {
const text = 'JOIN ME IN SPLATTER TENNIS — THE CODE IS ' + (G.net && G.net.code || '');
try {
if (navigator.share) await navigator.share({ title: 'SPLATTER TENNIS', text });
else { await navigator.clipboard.writeText(text); vsSay('vsMsg', 'COPIED — SEND IT TO THEM'); }
} catch { /* a cancelled share sheet is not an error */ }
});
const vsQuit = () => { endNetVs(); vsOpen(); }; // a room you walked out of must give its socket back
document.getElementById('vsCancel').addEventListener('click', vsQuit);
document.getElementById('vsBack').addEventListener('click', () => {
if (!vsWait.hidden) return vsQuit(); // waiting: the chevron cancels rather than leaves
endNetVs(); goMode();
});
// the three hooks net.js calls back through — all of them no-ops once the court has been taken
netHooks.paired = () => start('classic'); // start() refuses when the menu is already down (URL entry)
netHooks.room = code => { vsSay('vsBig', code); vsSay('vsMsg', 'WAITING FOR THEM TO JOIN'); };
netHooks.lost = text => {
if (!menuUp()) return false; // mid-match: the court's own card says it
endNetVs();
vsPick.hidden = false; vsWait.hidden = true;
vsSay('vsErr', text);
return true;
};
// --- tour tiles, built from the table. Same "tap selects, SERVE UP starts" rule as the court rows —
// the setup screen picks WHO and WHERE, and nothing on it commits you.
const tourList = document.getElementById('tours');
for (const t of TOURS) {
const b = document.createElement('button');
b.className = t.id === pickedTour ? 'on' : '';
b.dataset.id = t.id; // syncSetup finds the row's buttons by id
// just the word now — the live player beside it is the portrait, and the tile art was a SECOND
// drawing of the same man that no colour on this screen could reach
b.textContent = t.name.replace(' SINGLES', '');
b.addEventListener('click', () => {
pickedTour = t.id;
for (const el of tourList.children) el.classList.toggle('on', el === b);
refreshLook();
});
tourList.appendChild(b);
}
// THE LIVE PLAYER, redrawn on every tap of a tour, a kit or a skin. It draws the REAL sprite through
// the REAL tint (drawLook), so there is no second recolour to keep in step with the first — the only
// way this can show a wrong colour is if the court would show it too.
// The sprite sheets load asynchronously and the menu deliberately parks the game loop, so the first
// draw can land before the art does: retry on a slow poll that stops itself the moment it succeeds.
const lookCv = document.getElementById('look'), pLookCv = document.getElementById('pLook'),
cdLookCv = document.getElementById('cdLook');
let lookPoll = 0;
function refreshLook() {
const t = TOURS.find(x => x.id === pickedTour) || TOURS[0];
const k = KITS.find(e => e.id === pickedKit) || KITS[0];
const s = SKINS.find(e => e.id === pickedSkin) || SKINS[0];
// both live players — the setup screen's and the identity screen's — are the same man and must
// move together; drawing to whichever is hidden is a no-op-cheap blit
const ok = drawLook(lookCv, t, k, s) & drawLook(pLookCv, t, k, s) & drawLook(cdLookCv, t, k, s);
clearTimeout(lookPoll);
if (!ok) lookPoll = setTimeout(refreshLook, 200); // sprites still coming; ask again shortly
}
// --- KIT and SKIN swatches, built from the tables. Same rule as the tour tiles: tapping selects and
// nothing more, SERVE UP is what commits. Both rows are one builder — they differ only in which
// table they read and which localStorage key they remember, and a second copy of this loop to say
// "skin" instead of "kit" would be the same eleven lines twice.
const swatches = (rowId, nameId, table, keyName, get, set) => {
const row = document.getElementById(rowId), label = document.getElementById(nameId);
for (const e of table) {
const b = document.createElement('button');
b.className = e.id === get() ? 'on' : '';
b.dataset.id = e.id; // syncSetup finds the row's buttons by id
b.style.background = e.sw;
b.title = e.name; // desktop hover; the h3 label is what a phone reads
b.setAttribute('aria-label', e.name);
b.addEventListener('click', () => {
set(e.id);
try { localStorage.setItem(keyName, e.id); } catch {}
for (const el of row.children) el.classList.toggle('on', el === b);
label.textContent = e.name;
refreshLook();
});
row.appendChild(b);
}
label.textContent = (table.find(e => e.id === get()) || table[0]).name;
};
let pickedKit = remembered('kit') || KITS[0].id;
let pickedSkin = remembered('skin') || SKINS[0].id;
swatches('kitRow', 'kitName', KITS, 'kit', () => pickedKit, id => pickedKit = id);
swatches('skinRow', 'skinName', SKINS, 'skin', () => pickedSkin, id => pickedSkin = id);
refreshLook();
// THE CLIMB PANEL: the arcade's setup face — the marathon's length (grown by every promotion)
// and your best run. Reuses the league panel's furniture; the foes row stays empty (up to 18
// tiles is a wall, and the training picker already shows the roster face-up).
function buildClimbPanel() {
const lg = leagueOf(profile.prof.lg);
document.getElementById('lgBadge').src = `assets/badge_l${lg.id}.png?v=${SPRV}`;
const name = document.getElementById('lgName');
name.textContent = 'THE CLIMB';
name.style.color = '';
const best = stats.read('all', undefined, stats.MODE_MATCH).longest;
// two deliberate lines — as one string the browser broke it wherever it fell (Oliver)
document.getElementById('lgSub').innerHTML =
`THE CLUB FROM THE BOTTOM — ${lg.band[1]} STAND` + (best ? `<br>BEST RUN: ${best} FELLED` : '');
document.getElementById('lgFoes').innerHTML = '';
}
// THE LEAGUE PANEL (the leagues): the cup's setup face — your league writ large and the band's
// roster with your all-time felled counts (stats' per-foe tallies, key 'L'+index). The identity
// block these panels replace still shows for every other mode; the card owns it anyway.
function buildLeaguePanel() {
const lg = leagueOf(profile.prof.lg);
const badge = document.getElementById('lgBadge');
badge.src = `assets/badge_l${lg.id}.png?v=${SPRV}`;
const name = document.getElementById('lgName');
name.textContent = `LEAGUE ${lg.id} — ${lg.name}`;
name.style.color = lg.color;
const z = stats.read('all', undefined, stats.MODE_MATCH).z;
const foes = document.getElementById('lgFoes');
foes.innerHTML = '';
let felled = 0;
for (let n = lg.band[0]; n <= lg.band[1]; n++) {
const f = matchFoe(n), k = z[f.key] || 0;
if (k) felled++;
const d = document.createElement('div');
d.className = 'lgFoe' + (k ? '' : ' un') + (n === lg.band[1] ? ' fin' : '');
d.innerHTML = `<img src="${foeArt(f)}" alt=""><b>${f.short}</b><i>${k ? '×' + k : '—'}</i>`;
foes.appendChild(d);
}
const size = lg.band[1] - lg.band[0] + 1;
// ...and THE RECORD (THE SEASON): how far this league's cup has ever let you get. It is the one
// line that makes a lost attempt count for something, so it takes the second half of the sub
// whenever there is one to show.
const best = profile.cupBest(lg.id);
document.getElementById('lgSub').innerHTML = (felled >= size
? 'ALL ' + size + ' FELLED'
: felled + ' OF ' + size + ' FELLED')
+ (best ? '<br>BEST IN THE CUP: ' + cupRoundName(lg.id, best)
: '<br>THE CUP IS THE WAY UP — NEVER ENTERED');
}
// The setup screen's rows, brought up to date with picks made elsewhere (the identity screen).
// Selection state lives in picked*; the rows are just its picture and must be repainted on entry.
// On the LEAGUE MODES the player block steps aside for the league panel (Oliver: the identity is
// on the card now — the setup should show the league and who of it you have already beaten).
function syncSetup() {
const cupMode = pickedMode === 'cup', climbMode = pickedMode === 'zombie';
document.getElementById('cStart').textContent = cupMode ? 'ENTER THE CUP' : 'SERVE UP';
document.getElementById('who').style.display = cupMode || climbMode ? 'none' : 'flex';
document.getElementById('lgPanel').style.display = cupMode || climbMode ? 'block' : 'none';
// THE TEXT REDUCTION: two blocks on one screen, not two numbered steps
document.getElementById('s01').textContent =
cupMode ? 'YOUR LEAGUE' : climbMode ? 'THE CLIMB' : 'PLAYER';
if (cupMode) buildLeaguePanel(); else if (climbMode) buildClimbPanel();
if (!courtUnlocked(picked)) picked = leagueOf(profile.prof.lg).court;
// the COURT section: the CUP is the league fixture — one ringed row, the home venue, no choice
// (Oliver: a league HAS its court). The climb and every other mode keep the full pickable list.
const homeCourt = leagueOf(profile.prof.lg).court;
// ...and the meters get their legend ONCE, in the heading, instead of a line under every court
document.getElementById('s02').innerHTML = cupMode
? 'HOME COURT<span>SET BY YOUR LEAGUE</span>' : 'COURT<span>PACE · BOUNCE</span>';
courtList.classList.toggle('fixed', cupMode);
for (const el of courtList.children) {
const c = COURTS.find(x => x.id === el.dataset.id), l = courtLeague(el.dataset.id);
const open = courtUnlocked(el.dataset.id);
el.classList.toggle('lock', !open && !cupMode);
el.classList.toggle('on', cupMode ? el.dataset.id === homeCourt
: open && el.dataset.id === picked);
el.style.display = cupMode && el.dataset.id !== homeCourt ? 'none' : '';
// an OPEN court says nothing — its two meters say it wordlessly (the text reduction). A locked
// one names the league that opens it, and nothing else: the tile stays visible, as a goal should
const sub = el.querySelector('i');
sub.textContent = open ? '' : 'LEAGUE ' + l.id;
sub.hidden = open;
}
for (const el of tourList.children) el.classList.toggle('on', el.dataset.id === pickedTour);
for (const [rowId, nameId, table, cur] of [['kitRow', 'kitName', KITS, pickedKit],
['skinRow', 'skinName', SKINS, pickedSkin]]) {
for (const el of document.getElementById(rowId).children)
el.classList.toggle('on', el.dataset.id === cur);
document.getElementById(nameId).textContent = (table.find(e => e.id === cur) || table[0]).name;
}
refreshLook();}
// --- THE IDENTITY SCREEN (the leagues, phase 1). First boot only: name + tour + colours, then
// ENTER THE CLUB. It writes the SAME stores the setup screen reads (pickedTour/Kit/Skin, their
// localStorage keys, stats' name key), so the two screens cannot disagree — which is why the rows
// are REBUILT on every visit: their .on states have to reflect taps made on the other screen.
const pName = document.getElementById('pName');
function buildPlayer() {
pName.value = stats.savedName() || profile.zombieName();
const tRow = document.getElementById('pTours');
tRow.innerHTML = '';
for (const t of TOURS) {
const b = document.createElement('button');
b.className = t.id === pickedTour ? 'on' : '';
b.textContent = t.name.replace(' SINGLES', '');
b.addEventListener('click', () => {
pickedTour = t.id;
try { localStorage.setItem('tour', t.id); } catch {}
for (const el of tRow.children) el.classList.toggle('on', el === b);
refreshLook();
});
tRow.appendChild(b);
}
document.getElementById('pKitRow').innerHTML = '';
document.getElementById('pSkinRow').innerHTML = '';
swatches('pKitRow', 'pKitName', KITS, 'kit', () => pickedKit, id => pickedKit = id);
swatches('pSkinRow', 'pSkinName', SKINS, 'skin', () => pickedSkin, id => pickedSkin = id);
refreshLook();
}
document.getElementById('pDice').addEventListener('click', () => { pName.value = profile.zombieName(); });
document.getElementById('pBack').addEventListener('click', () => {
if (playerReturn === 'card') { playerReturn = null; buildCard(); go('card'); return; }
go('splash'); // setup stays unset — it re-asks
});
document.getElementById('pGo').addEventListener('click', () => {
// an emptied field is not a refusal to have a name — the die decides, like the prefill did
stats.saveName(stats.tidyName(pName.value) || profile.zombieName());
try { localStorage.setItem('tour', pickedTour); } catch {}
profile.prof.setup = 1; profile.save(); profile.push();
if (playerReturn === 'card') { playerReturn = null; buildCard(); go('card'); return; }
buildTops(); go('mode');
});
pName.addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('pGo').click(); });
// --- court rows, built from the table.
// The two meters come out of the court's OWN bounce numbers rather than a pair of hand-written
// ratings, so a physics change moves the bars with it and the screen can never advertise a court
// that plays differently from what it says.
// SPEED how much pace survives the bounce — bounceXZ, grass .84 fastest, clay .70 slowest
// BOUNCE how much of it comes back UP — bounceY, clay .66 highest, grass .50 lowest
// The two divisors just stretch those narrow real ranges across a full bar; they carry no physics.
// the label is a TITLE, not a printed word (the text reduction): the legend stands once over the
// list — two bars in a fixed order need naming once, not on every row
const meter = (label, v, cls = '') =>
`<div class="meter ${cls}" title="${label}"><s><i style="width:${
Math.round(Math.min(1, Math.max(0.08, v)) * 100)}%"></i></s></div>`;
const courtList = document.getElementById('courtList');
// THE LEAGUES lock the courts: a court belongs to its league and unlocks for EVERY mode the
// moment you reach that league (settled: the unlock is the reward formula). A court no league
// names stays free forever.
const courtLeague = id => LEAGUES.find(l => l.court === id);
const courtUnlocked = id => { const l = courtLeague(id); return !l || l.id <= profile.prof.lg; };
for (const c of COURTS) {
const b = document.createElement('button');
b.className = 'court' + (c.id === picked ? ' on' : ''); // your last court comes up pre-picked
b.dataset.id = c.id;
b.innerHTML = `<img src="assets/${c.tile}.png?v=${SPRV}" alt=""><span class="cb">`
+ `<b>${c.name}</b><i hidden></i>` // 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')
+ '</span>';
// 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 = `<img src="${foeArt(f)}" alt=""><span><b>${f.name}</b><i>PUT YOU OUT</i></span>`
+ '<em>&gt;</em>';
trainList.appendChild(b);
}
for (let n = 1; n <= top; n++) {
if (n === out) continue;
const [f, b] = rung(n);
// the rung is a NUMBER on a list of numbers — two digits, no "No."
b.innerHTML = `<img src="${foeArt(f)}" alt=""><b>${f.name}</b>`
+ `<i>${String(n).padStart(2, '0')}</i>`;
trainList.appendChild(b);
}
// ...and the list ENDS somewhere: the next league's band is drawn as the goal it is, not left
// to stop dead at the cap with no explanation (the handoff's "Ziel, keine Liste")
if (top < LADDER) {
const next = LEAGUES.find(l => l.band[0] > top);
const goal = document.createElement('div');
goal.className = 'goal';
if (next) goal.innerHTML = `<b>LEAGUE ${next.id}</b><i>${next.band[0]} — ${next.band[1]}</i>`;
trainList.appendChild(goal);
}
}
document.getElementById('trBack').addEventListener('click', () => go('setup'));
// --- THE CHARACTER CARD (the leagues, phase 3). The splash's level strip is the door. The
// honours row is a COLLECTION: every slot always visible, earned badges bright, the rest dark —
// the empty slots are the incentive. The ledger reads the same stats module the BODY COUNT does.
const fig = (label, val) => `<div class="fig"><b>${label}</b><i>${val}</i></div>`;
// THE CARD COUNTS UP (Oliver): bars sweep in and every number climbs fast on entry. setTimeout,
// not rAF — the menu parks the game loop (the bracket's own rule). Timers are cancelled on the
// next build so a re-entry never races itself.
let cardTimers = [];
const countUp = (write, target, dur = 700) => {
const t0 = performance.now();
(function tick() {
const p = Math.min(1, (performance.now() - t0) / dur);
write(Math.round(target * (1 - Math.pow(1 - p, 3)))); // easeOutCubic: fast, then settles
if (p < 1) cardTimers.push(setTimeout(tick, 30));
})();
};
function buildCard() {
for (const t of cardTimers) clearTimeout(t);
cardTimers = [];
const lg = leagueOf(profile.prof.lg), pr = profile.progress();
document.getElementById('cdName').textContent = stats.savedName() || 'THE WALK-ON';
document.getElementById('cdBadge').src = `assets/badge_l${lg.id}.png?v=${SPRV}`;
const el = document.getElementById('cdLeague');
el.textContent = `LEAGUE ${lg.id} OF ${LEAGUES.length} — ${lg.name}`;
el.style.color = lg.color;
document.getElementById('cdLevel').textContent =
'LEVEL ' + pr.level + ' OF ' + profile.LEVEL_CAP;
document.getElementById('cdFrom').textContent = pr.level;
document.getElementById('cdTo').textContent = pr.need ? pr.level + 1 : 'MAX';
// both bars sweep from empty to their mark (the 0.7s eased transition in the CSS)
for (const [id, w] of [['cdFill', Math.round(pr.frac * 100)],
['cdCareerFill', Math.min(100, Math.round(profile.prof.xp / profile.xpFor(profile.LEVEL_CAP) * 100))]]) {
const bar = document.getElementById(id);
bar.style.transition = 'none'; bar.style.width = '0%';
void bar.offsetWidth; // commit the empty bar before the sweep
bar.style.transition = '';
cardTimers.push(setTimeout(() => { bar.style.width = w + '%'; }, 40));
}
const xpTail = pr.need
? ' · ' + (pr.need - pr.into).toLocaleString('en-GB') + ' TO LEVEL ' + (pr.level + 1)
: ' · THE CAP';
const cdXp = document.getElementById('cdXp');
countUp(v => cdXp.textContent = v.toLocaleString('en-GB') + ' XP' + xpTail, profile.prof.xp);
const row = document.getElementById('cdBadges');
row.innerHTML = '';
for (const l of LEAGUES) {
const img = document.createElement('img');
img.src = `assets/badge_l${l.id}.png?v=${SPRV}`;
img.title = l.name;
// a league's badge is EARNED by finishing it — winning its cup. Being past it means exactly
// that; league five has no "past", so its shield lights with the champion's crown.
img.className = l.id < profile.prof.lg || (l.id === LEAGUES.length && profile.prof.ch) ? '' : 'dark';
row.appendChild(img);
}
const champ = document.createElement('img');
champ.src = `assets/badge_champ.png?v=${SPRV}`;
champ.title = 'CHAMPION OF THE DEAD CLUB';
champ.className = profile.prof.ch ? '' : 'dark';
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 HOLD<u>EVERY CUP FROM HERE IS A DEFENCE</u>`
: `NEXT: ${fx.title}<u>${fx.line}</u>`;
}
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 =
`<b>${run.score.toLocaleString('en-GB')}</b> ${B.unit} · <b>${run.sub}</b> ${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 = `<img src="assets/${art.walk[0]}.png?v=${SPRV}" alt="">`
+ `<b>${art.name}</b><i>CHAPTER ${ch} · GAME ${ch * TUNE.bossEvery}</i>`;
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 <img> tags on the same files —
// or start a rally while half the sheets were still in flight, and the missing ones drew as
// whatever the fallback was. onerror counts too (render.js), so a dead network still finishes the
// bar rather than parking the player on it forever.
const splash = document.getElementById('splash'), bootFill = document.querySelector('#boot i');
splash.classList.add('busy'); // belt and braces: the class ships in index.html so the very
// first paint is already the loading state — this only matters if
// that ever gets dropped from the markup
const loaded = () => !splash.classList.contains('busy');
// setInterval, NOT requestAnimationFrame: rAF does not fire in a hidden tab, and a phone that
// launches the app and locks — or an install that loads in the background — would come back to a
// bar frozen at whatever it reached and a menu with no buttons on it. An interval still ticks
// (throttled to about a second), so the gate always opens.
const tick = setInterval(function watchLoad() {
const frac = LOAD.total ? LOAD.done / LOAD.total : 1;
bootFill.style.width = (frac * 100).toFixed(1) + '%';
if (frac < 1 || !profileDone) return; // sprites AND the profile mirror (or its 4 s cap)
clearInterval(tick);
// fonts.ready resolves once nothing is pending — including the three faces warmed above — so the
// first canvas frame is never the system serif.
(document.fonts ? document.fonts.ready : Promise.resolve())
.then(() => { splash.classList.remove('busy'); dealStage('splash'); });
}, 100);
// THE FIRST TAP ANYWHERE ON THE OVERLAY STARTS THE THEME, and does nothing else — the screens have
// real buttons now. This is the gesture the autoplay policy is waiting for, which is why the theme
// is not started at boot. menuMusic itself is a no-op once the bed exists (loopBed's beds.has
// guard), so coming back from a run picks up the track already playing rather than restarting it.
intro.addEventListener('pointerdown', e => { menuMusic(); if (e.target.closest('button')) haptic('select'); });
// which build am I looking at? Push = deploy and the browser cannot see git, so the served file's
// own timestamp IS the version marker — it changes on every deploy, with nothing to remember to bump.
// Match it against `git log -1 --format=%cd` when a phone looks suspiciously out of date.
fetch('src/main.js', { method: 'HEAD' }) // page-relative: index.html sits at the root, the code in src/
.then(r => { const t = r.headers.get('last-modified'); if (!t) return;
document.getElementById('build').textContent = 'BUILD ' + new Date(t).toLocaleString('en-GB').replace(',', ''); })
.catch(() => {}); // no header, no line — never a broken start screen
// Keyboard, per stage. On the splash Enter/Space goes to setup and S opens the stats; on the setup
// screen 1..N picks a court and Enter starts; Escape backs out to the splash from anywhere.
addEventListener('keydown', e => {
if (!menuUp()) return;
menuMusic(); // a key is a gesture too — unlock the theme on a desktop that never taps
const stage = intro.dataset.stage;
if (stage === 'splash' && !loaded()) return; // the boot gate holds the keys too
if (e.key === 'Escape') return go('splash');
if (stage === 'splash') {
if (!loaded()) return; // the boot bar hides the buttons; a key must not walk past it
if (e.key === 'Enter' || e.key === ' ') goMode();
else if (e.key === 's' || e.key === 'S') openStats();
return;
}
if (stage !== 'setup') return; // stats and ranks are read-only: Escape is the whole interface
const n = parseInt(e.key, 10);
if (n >= 1 && n <= COURTS.length) courtList.children[n - 1].click();
// through the BUTTON, not start() — the cup's draw screen sits between the two and a keyboard
// must not be the one way to walk past it
else if (e.key === 'Enter' || e.key === ' ') document.getElementById('cStart').click();
});
// A run the player walks out on is still a run. killPlayer banks a death; this banks everything
// else — the tab closed mid-match, the phone's browser killed in the background. `pagehide` is the
// one that actually fires on iOS, where beforeunload/unload do not. endRun is idempotent and
// no-ops on an empty run, so closing the tab at the menu writes nothing.
addEventListener('pagehide', () => {
profile.push(); // mirror whatever the run has earned — keepalive lets it survive the unload
if (G.mode !== 'splatter') return;
if (G.drill) return; // the drill banks nothing — its kills leak into the gore
// bag otherwise (killPlayer's drill guard covers death,
// not the tab-close path)
if (G.match && G.match.train) return; // the training court banks nothing, walked out on or not
// ...tagged with the mode that was being played, and carrying THAT mode's two headline numbers —
// see the record-shape note in stats.endRun. A match abandoned mid-rally is a match run.
if (G.match) stats.endRun(G.match.cup ? G.match.cup.round : (G.match.felled ?? Math.max(0, G.match.opp - 1)),
score.games[0] + score.games[1], G.splatT, false, stats.MODE_MATCH);
else stats.endRun(G.runGames, G.sets[0], G.splatT, false);
});
// eaten: any tap or key returns to the title — but only once the death animation has played out, so
// a panic tap during the gore burst can't skip it. A modal owns its own gesture (see input.js).
// NOT a page reload any more: that reset everything honestly but also tore down the AudioContext, so
// the theme restarted from silence. restartRun puts every bag back from a pristine copy, and the
// bed that has been playing under the death summary simply carries on into the title screen.
const maybeRestart = e => {
// ...and the classic ending taps out the same way, on its own shorter clock (G.done, no corpse)
const ready = G.done ? G.done.t > DONE_RESTART : G.mode === 'eaten' && G.eatenT > EATEN_RESTART;
if (G.modal || !ready) return;
// WHICH DOOR (THE SEASON: "das ende reicht weiter"). The summary lays its own buttons out as it
// draws — it measures its box, so only it knows where they landed — and hands the rects back
// through endHit. A tap anywhere ELSE is the old behaviour exactly: back to the club.
let door = null;
if (e && e.clientX !== undefined) {
const r = cv.getBoundingClientRect();
door = endHit((e.clientX - r.left) / r.width, (e.clientY - r.top) / r.height);
}
restartRun();
profile.push(); // the run's xp is banked — mirror it while the moment is quiet
refreshSplash(); // the run just ended: the splash count has moved
intro.hidden = false;
quitChip.hidden = true; // the door out goes with the run it belonged to
// stats.lastRun() survives restartRun on purpose — it lives in the stats module, not on G, which
// has just been restored from PRISTINE. Consumed on read, so the prompt appears once.
const run = stats.lastRun();
// THE PROMOTION plays first (the leagues): the ceremony the cup win parked, then the name
// prompt it may also have earned (prGo hands the run on), then the title.
const promo = profile.takePromo();
if (promo) { buildPromo(promo, run); go('promo'); return; }
// ...on the BOARD's number, not on kills: a classic match has none, so the gore-shaped test here
// silently barred two of the three modes from ever reaching the prompt
// ...on a board that still EXISTS: classic tennis left the world board with the stats streamline
// (out of competition, by its own room's rule), so a classic best has nowhere to be written
if (run && run.newKillBest && run.score > 0 && run.mode !== stats.MODE_CLASSIC) { askName(run); return; }
// the fixture button restarts the canonical path without a detour through the menus; PLAY AGAIN
// re-deals the mode that just ended (pickedMode is what started it, cup included)
if (door === 'next') { pickMode('cup'); start('cup'); return; }
if (door === 'again') { start(pickedMode); return; }
go('splash');
};
cv.addEventListener('pointerdown', maybeRestart);
addEventListener('keydown', maybeRestart);
// THE DOOR OUT (Oliver: every tennis screen lacked an exit — force-closing the app was the only
// way back). The chip shows for the whole run; tapping it (or Escape on desktop) freezes the
// world and asks. Quitting is the death path minus the death: restartRun resets everything and
// nothing is banked — an abandoned run never happened, statistically.
const quitChip = document.getElementById('quit'), qAsk = document.getElementById('qAsk');
document.getElementById('qChip').addEventListener('click', () => { paused = true; qAsk.hidden = false; });
document.getElementById('qNo').addEventListener('click', () => { qAsk.hidden = true; paused = false; });
document.getElementById('qYes').addEventListener('click', () => {
qAsk.hidden = true; paused = false; quitChip.hidden = true;
restartRun();
profile.push(); // a quit banks no run, but the xp already awarded is real — mirror it
refreshSplash();
intro.hidden = false;
go('splash');
});
addEventListener('keydown', e => {
if (e.key !== 'Escape' || !intro.hidden || quitChip.hidden) return;
qAsk.hidden = !qAsk.hidden;
paused = !qAsk.hidden;
});
// A tap on the SCOREBOARD toggles the miss-log overlay, and that is now all it does. The triple-tap
// that opened test mode has moved to the back room on the splash (see below): a cheat you can trip
// by accident mid-run is a bug, and this one sat on the one thing you tap to read the log.
cv.addEventListener('pointerdown', e => {
const r = cv.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width * (cv.width / RES); // logical coords
const y = (e.clientY - r.top) / r.height * (cv.height / RES);
if (x >= 6 && x <= 138 && y >= 6 && y <= 36) G.logShow = !G.logShow; // the scoreboard
});
// ---- A POSED SCENE for tuning a shot: `T.laserScene()`, or load with #laser. Six bodies of
// different types at known depths, the laser armed, a pull held.
// IT DOES NOT PAUSE, deliberately. The first version froze the world so the picture would hold
// still — and then every TUNE value you changed did nothing visible, because nothing redrew. The
// bodies are held still individually (v = 0) and spawning is off instead, so the picture is stable
// while the render loop keeps running and an edit shows on the very next frame.
function laserScene() {
const intro = document.getElementById('intro');
if (intro) intro.hidden = true;
paused = false;
if (G.mode === 'tennis') forceZombies();
for (let i = 0; i < 600; i++) update(1 / 60); // ride the transition out into splatter
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.bossFight = 0; G.hp = G.hpMax = TUNE.playerHp;
zombies.length = 0; pellets.length = 0; parts.length = 0; gore.length = 0; piles.length = 0;
score.server = 'P'; setupPoint();
P.x = 0;
TUNE.zMax = 0; // nothing new wanders into the shot
for (const [x, z, v] of [[0, 20, 'a'], [0.75, 16, 'b'], [-0.85, 12, 'k'],
[0.85, 8.5, 'g'], [-0.75, 5.5, 'h'], [0, 3, 'e']]) {
spawnZombie(x, z, v);
zombies[zombies.length - 1].v = 0; // held still: the scene is a photograph
}
G.rack.length = 0; G.temp = null;
G.weapon = grantWeapon('laser');
G.modal = null;
G.aim = { fx: 0, fy: -1, mag: 100 }; // a full pull straight up-court (fy is NEGATIVE)
return 'posed and LIVE — change TUNE (T panel or console) and it redraws. T.laserFire() to shoot it.';
}
// fire the posed shot, then pose it again so you can keep tuning
function laserFire() {
G.aim = null; score.server = 'P'; setupPoint();
keyPressed('Space');
return 'fired — T.laserScene() to re-pose';
}
// a plain timer, not the load event: this module runs as a module, so `load` may already have gone
if (location.hash === '#laser') setTimeout(laserScene, 600);
// #drill — the same door from a URL, so a phone can be sent straight into the mode
if (location.hash === '#drill') setTimeout(() => { start(null); intoSplatter(); startDrill(); }, 700);
if (location.hash === '#match') setTimeout(() => { start(null); intoSplatter(); startMatch(); }, 700);
if (location.hash === '#cup') setTimeout(() => { start(null); intoSplatter(); startMatch(true); }, 700);
if (location.hash === '#bench') setTimeout(() => { start(null); intoSplatter(); startBench(); }, 700);
// #net opens the lobby, #net-ABCD opens it with a friend's code already in the field — that link
// IS what the SHARE button sends, so the invited phone lands one tap from the court (the tap is
// not ceremony: it is where they see, and can change, the name the other phone will call them).
if (location.hash.startsWith('#net')) setTimeout(() => vsOpen(location.hash.slice(5).toUpperCase()), 700);
requestAnimationFrame(frame);
// exposed for in-browser sanity checks and simulated playtests
window.T = { TUNE, ball, P, O, score, G, zombies, keyPressed, SPR, laserScene, laserFire, pause: v => { paused = v !== false; }, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, spawnBarrel, grantWeapon, toggleTestMode, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, dragServe, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch,
stats, step: dt => update(dt), render, press: keyPressed, FALL, benchFall,
start, intoSplatter, startHorde, sendBoy, // the two run doors + the wave: the promo rig (tools/) stages every clip through them
get phase() { return G.phase; }, set phase(v) { G.phase = v; } };