// 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 , storyFixture, storyAct , STORY, ROMAN, storyBossArt , STORY_HELP, STORE } 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 , startStory , storyUI } from './beats.js';
import { netConnect, netHooks, netAgain } from './net.js';
import { fitViewport, render, proj, RES, SPRV, LOAD, SPR, drawLook, endHit, logGeom } from './render.js';
import { voice, ambient, menuMusic, menuDuck, sfx, boom, whoosh, applause, pock } 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 * as recorder from './recorder.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);
// One opaque overlay stays mounted across every menu; the court is only revealed on start.
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 === 'splash') refreshSplash();
if (stage === 'stats') refreshRecorder();
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');
}
// Additional solo modes. League, Gore and multiplayer have their own splash doors.
let pickedMode = remembered('mode') || MODES[0].id;
let pickedDiff = remembered('diff') || 'medium';
const modeOf = id => MODES.find(x => x.id === id);
const modeList = document.getElementById('modeList');
const pickMode = id => { pickedMode = id; try { localStorage.setItem('mode', id); } catch {} };
// THE ARCADE OPENS WITH THE STORY (Oliver, 2026-10-15: "nur die bereits über die Hauptstory
// freigeschalteten Spielmodi spielbar machen"): a door is open once the act that unlocks it is cleared
// (config.STORY acts[].arcade), and everything is open once the story is told. A locked door is shown,
// greyed, with the act that opens it — pointed at, never hidden. STORE.arcadePremium is the stub for
// making the arcade a premium door; premium() answers true until the unlock IAP exists.
const premium = () => true;
const arcadeAct = id => STORY.acts.findIndex(a => a.arcade.includes(id)) + 1; // 0 = never gated
const arcadeOpen = id => {
const n = arcadeAct(id), s = profile.prof.story;
return (!n || s.cp === 'done' || s.act > n) && (!STORE.arcadePremium || premium());
};
const lockLine = id => 'CLEAR ACT ' + ROMAN[arcadeAct(id)] + ' TO OPEN';
function buildTops() {
modeList.innerHTML = '';
for (const { mode } of MENU) {
const card = modeOf(mode), b = document.createElement('button'), open = arcadeOpen(mode);
b.className = 'mode' + (open ? '' : ' locked');
b.style.backgroundImage = `url(assets/${card.art}.png?v=${SPRV})`;
b.innerHTML = `${card.name}${open ? '' : `${lockLine(mode)}`}`;
b.addEventListener('click', () => {
if (!arcadeOpen(mode)) { b.classList.remove('nope'); void b.offsetWidth; b.classList.add('nope'); return; }
pickMode(mode);
go(mode === 'train' ? 'train' : 'setup');
});
modeList.appendChild(b);
}
}
buildTops();
const difficultyList = document.getElementById('difficultyList');
for (const d of DIFFS) {
const b = document.createElement('button');
b.textContent = d.name;
b.dataset.id = d.id;
b.addEventListener('click', () => {
pickedDiff = d.id;
try { localStorage.setItem('diff', d.id); } catch {}
syncSetup();
});
difficultyList.appendChild(b);
}
// ---- 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 = '') {
// hosting is the arcade door act IV opens; joining a friend's room is always free
const host = arcadeOpen('vs');
for (const id of ['vsHostC', 'vsHostD']) document.getElementById(id).disabled = !host;
document.getElementById('vsHostLock').textContent = host ? '' : lockLine('vs');
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(); go('splash');
});
// 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;
};
// 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 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];
// The editor and profile draw the same kit; drawing the hidden one is a cheap blit.
const ok = 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;
refreshLook();
// 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 = `${f.short}${k ? '×' + k : '—'}`;
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 ? '
BEST IN THE CUP: ' + cupRoundName(lg.id, best)
: '
THE CUP IS THE WAY UP — NEVER ENTERED');
}
// A setup contains only decisions for this match. Identity lives in the player screen.
function syncSetup() {
document.getElementById('sTitle').textContent = modeOf(pickedMode).name;
document.getElementById('sPlayerName').textContent = stats.savedName() || 'YOUR PLAYER';
document.getElementById('sPlayerKit').textContent =
(KITS.find(k => k.id === pickedKit) || KITS[0]).name;
document.getElementById('sDifficulty').hidden = pickedMode !== 'classic';
for (const b of difficultyList.children) {
b.classList.toggle('on', b.dataset.id === pickedDiff);
b.setAttribute('aria-pressed', b.dataset.id === pickedDiff);
}
const opponent = document.getElementById('sOpponent');
opponent.hidden = pickedMode !== 'train';
if (pickedMode === 'train') {
const f = matchFoe(trainRung);
opponent.querySelector('img').src = foeArt(f);
opponent.querySelector('b').textContent = f.name;
}
const climb = document.getElementById('sClimb');
climb.hidden = pickedMode !== 'zombie';
climb.textContent = `THE CLUB, BOTTOM UP · ${leagueOf(profile.prof.lg).band[1]} OPPONENTS`;
if (!courtUnlocked(picked)) picked = leagueOf(profile.prof.lg).court;
let locked = 0;
for (const el of courtButtons) {
const open = courtUnlocked(el.dataset.id);
el.disabled = !open;
el.classList.toggle('lock', !open);
el.classList.toggle('on', open && el.dataset.id === picked);
el.setAttribute('aria-pressed', open && el.dataset.id === picked);
const sub = el.querySelector('.cb > i');
sub.textContent = open ? '' : 'LEAGUE ' + courtLeague(el.dataset.id).id;
sub.hidden = open;
(open ? courtList : lockedCourtList).appendChild(el);
if (!open) locked++;
}
document.getElementById('lockedCourts').hidden = !locked;
document.getElementById('lockedCourtLabel').textContent = `LOCKED COURTS · ${locked}`;
}
// --- 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() {
document.getElementById('pGo').textContent = profile.prof.setup ? 'SAVE PLAYER' : 'ENTER THE CLUB';
document.getElementById('pNote').hidden = !!profile.prof.setup;
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(); });
function leavePlayer(saved) {
const back = playerReturn; playerReturn = null;
if (back === 'card') { buildCard(); go('card'); }
else if (back === 'setup') go('setup');
else if (back === 'cup' && saved) { pickMode('cup'); start('cup'); }
else if (back === 'story' && saved) start('story');
else go('splash');
}
document.getElementById('pBack').addEventListener('click', () => leavePlayer(false));
document.getElementById('pGo').addEventListener('click', () => {
stats.saveName(stats.tidyName(pName.value) || profile.zombieName());
try { localStorage.setItem('tour', pickedTour); } catch {}
profile.prof.setup = 1; profile.save(); profile.push();
leavePlayer(true);
});
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 = '') =>
`
`
+ `${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 (!courtUnlocked(c.id)) return; // locked: the row itself says which league opens it
picked = c.id;
try { localStorage.setItem('court', picked); } catch {}
syncSetup();
});
courtButtons.push(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 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}`;
// Training chooses the opponent first, then the shared setup confirms the court and starts.
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; go('setup'); });
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 rows = [['LEAGUE', profile.prof.lg], ['CUPS WON', profile.prof.lg - 1 + profile.prof.ch],
['BEST CUP', profile.cupBest(profile.prof.lg)]];
const box = document.getElementById('cdFigs1');
box.innerHTML = rows.map(([label]) => fig(label, '0')).join('');
[...box.querySelectorAll('.fig i')].forEach((cell, i) => {
countUp(v => cell.textContent = v.toLocaleString('en-GB'), rows[i][1]);
});
buildLeaguePanel();
refreshLook();
}
document.getElementById('plaque').addEventListener('click', () => { buildCard(); go('card'); });
document.getElementById('cdBack').addEventListener('click', () => go('splash'));
let playerReturn = null; // The editor returns to its caller; first-time cup entry resumes the cup.
document.getElementById('cdEdit').addEventListener('click', () => {
playerReturn = 'card'; buildPlayer(); go('player');
});
document.getElementById('sEdit').addEventListener('click', () => {
playerReturn = 'setup'; 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');
});
// Open an existing world board. Classic has no leaderboard; its fallback is Gore.
const boardFor = m => m === 'cup' ? stats.MODE_CUP
: m === 'zombie' || m === 'train' ? stats.MODE_MATCH : stats.MODE_GORE;
// The splash plaque opens the profile and its stats; the world board has its own door.
// Re-read on each arrival because the preceding run may have moved the level or league.
function refreshSplash() {
// THE PLAQUE — the meta identity, re-read whenever the splash comes back because a run has just
// moved it. 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.
// THE STORY IS THE MAIN PATH now (2026-10-15): the front door names where it stands
const fx = storyFixture(profile.prof.story);
document.getElementById('msTitle').textContent = fx.title;
// the arcade's gore door says what opens it while it is shut
const gOpen = arcadeOpen('gore'), mg = document.getElementById('mGore');
mg.classList.toggle('locked', !gOpen);
mg.querySelector('i').textContent = gOpen ? 'NO CUP. JUST CARNAGE.' : lockLine('gore');
document.getElementById('mCourt').style.display = gOpen ? '' : 'none'; // (its CSS display beats [hidden])
document.getElementById('msLine').textContent = fx.line;
document.getElementById('mCourt').textContent = 'COURT · ' +
(COURTS.find(c => c.id === picked) || COURTS[0]).name;
}
// `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 === 'story' ? leagueOf(storyAct(profile.prof.story.act).lg).court
: 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 STORY: the act's court, the splatter world under it, and the checkpoint the profile holds
// (the PROLOGUE is the one door that stays in 'tennis': a real match, the old turn armed in it)
if (modeId === 'story') { if (profile.prof.story.cp !== 'pro') intoSplatter(); startStory(); }
}
// Career totals and detailed records live one level beneath the profile.
const openStats = () => {
stats.drawStats('all', SPRV); go('stats');
};
// A phone can record without a console. Opt in from Stats or ?record=1; export is local/share
// sheet only. Keep this off the normal splash and give active recordings one small live marker.
const recordingMeta = () => ({
build: document.querySelector('script[src*="main.js"]').getAttribute('src'),
viewport: [innerWidth, innerHeight], pixelRatio: devicePixelRatio,
touch: matchMedia('(pointer: coarse)').matches,
});
function refreshRecorder() {
const r = recorder.recordingInfo();
document.getElementById('recLabel').textContent = 'PLAYTEST RECORDING' + (r.full ? ' · FULL' : r.active ? ' · ON' : '');
document.getElementById('recStatus').textContent = r.warning || (r.full
? 'Recording full. Export it, then start a new recording.'
: (r.active ? 'Recording' : r.exists ? 'Paused' : 'Off') + ' · ' + r.points + ' points saved on this phone.');
document.getElementById('recToggle').textContent = r.active ? 'STOP RECORDING' : r.exists ? 'RESUME RECORDING' : 'RECORD PLAYTEST';
document.getElementById('recToggle').disabled = r.full;
document.getElementById('recExport').disabled = !r.events;
document.getElementById('recNew').hidden = !r.exists || r.active;
const lamp = document.getElementById('recLamp');
lamp.hidden = !r.active && !r.full;
lamp.textContent = r.full ? 'REC FULL' : 'REC';
}
recorder.onRecorderChange(refreshRecorder);
document.getElementById('recToggle').addEventListener('click', () => {
const r = recorder.recordingInfo();
if (!r.exists) recorder.beginRecording(recordingMeta());
else recorder.setRecording(!r.active, recordingMeta());
});
document.getElementById('recNew').addEventListener('click', () => recorder.beginRecording(recordingMeta()));
document.getElementById('recExport').addEventListener('click', async () => {
try {
const result = await recorder.exportRecording();
if (result === 'download') document.getElementById('recStatus').textContent = 'Download requested. Your recording is also kept on this phone.';
} catch {
document.getElementById('recStatus').textContent = 'Export could not open. Your recording is kept; please try again.';
}
});
if (new URLSearchParams(location.search).get('record') === '1') {
if (recorder.recordingInfo().exists) recorder.setRecording(true, recordingMeta());
else recorder.beginRecording(recordingMeta());
}
refreshRecorder();
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
recorder.recordEvent('run_end', { outcome: 'backgrounded' }); recorder.saveRecording();
}
});
addEventListener('pagehide', () => recorder.saveRecording());
// ---- THE STORY's screens (2026-10-15, PROJECT.md "THE STORY", phase 3) ------------------------------
// In-game, animated, in the storyboard's look: the act title card, the cup's ceremony with its loot,
// and the act card. Each pauses the world while it is up (the same `paused` the quit chip uses) and
// resolves with the door that closed it. beats asks for them through storyUI.show.
const sty = document.getElementById('sty');
let styClose = null, styDoors = false;
const art = n => `assets/${n}.png?v=${SPRV}`;
const courtOf = act => { const lg = leagueOf(storyAct(act).lg); return { lg, court: COURTS.find(c => c.id === lg.court) || COURTS[0] }; };
const ARCADE_NAMES = { train: 'TRAINING', classic: 'CLASSIC TENNIS', zombie: 'THE CLIMB', gore: 'SPLATTER GORE', vs: 'VS A FRIEND' };
const delay = (k, more = '') => `style="animation-delay:${(k * 0.15).toFixed(2)}s;${more}"`; // one style attribute per element
// THE SOUND OF THE SCREENS (Oliver: "kleine Soundeffekte — die Waffen könnten das Klacken haben, das
// auch beim Waffenwechsel im Gore-Mode spielt"). Each cue is timed to the animation it belongs to and
// is dropped if the screen is tapped away first.
let styTimers = [];
const at = (secs, fn) => styTimers.push(setTimeout(fn, secs * 1000));
const CLICK = () => sfx('weapon-click2.mp3', 0.7); // the rack's own click (gameplay.selectWeapon)
function storyScreen(kind, d) {
const act = d.act, { lg, court } = courtOf(act);
let html = '', glow = '#3a1d16', auto = 0;
styTimers.forEach(clearTimeout); styTimers = [];
if (kind === 'act') {
auto = 2.8;
glow = '#1d2233';
html = `
`
+ `${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