Gore opens in the gore, the theme comes back, and every mode gets a board

Splatter gore no longer makes you win a game of tennis first — the mode you
pick is the mode you get, with the man you came to play already turned. The
transition itself is untouched and still reachable; it just isn't a toll booth.

The menu theme could only ever be started, never brought back, so the first
run of a session silenced it for good while the court's ambience followed you
into the menu. Starting a bed now un-ducks it too.

Three world boards instead of one: kills, opponents felled, and games taken
off the CPU, each with its own name prompt and its own row per player.

...and the gore economy stops paying a barrel for every game won past game 16,
while the shamblers, the mix and the ball machines keep escalating past the
point they all used to stop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-15 13:53:42 +02:00
1 parent 9cc3ac54b3
commit 3cf3e029a4
11 files changed
+440 -88

No files matched your search

+25 -3
View File
@@ -161,11 +161,14 @@ function loopBed(file, lowcut, vol, loopEnd = 0) {
})
.catch(() => beds.delete(file)); // failed start (decode/network) can retry next call
}
export const ambient = () => loopBed('tennis-ambient-loop.wav', 500, 0.035); // whole-scene atmosphere, from first tap
// whole-scene atmosphere, from the first tap. Un-ducks as well as starts, for the same reason
// menuMusic does: loopBed no-ops once the bed exists, so a SECOND run would have opened over the
// silence the menu left it in.
export const ambient = () => { loopBed('tennis-ambient-loop.wav', 500, 0.035); courtDuck(false); };
// ...and the splatter bed, same treatment: 5.9 MB of 24-bit WAV down to 287 KB. Its master is
// 982597 frames at 48 kHz, which is the un-round 20.47077 s below — an AAC loop needs the SOURCE
// length as loopEnd, not the decoder's padded one. See the note on menuMusic.
export const zombieMusic = () => loopBed('zombie-mode.m4a', 800, 0.2, 20.47077);
export const zombieMusic = () => { loopBed('zombie-mode.m4a', 800, 0.2, 20.47077); zombieDuck(false); };
// Menu theme: every menu screen and the death summary. Barely highpassed — this one is MUSIC, not
// an atmosphere bed, so the low end is the point. Started by the FIRST TAP (main.js `go`), never at
// boot: a title screen that starts playing at you before you have touched it is rude, and on a phone
@@ -175,7 +178,19 @@ export const zombieMusic = () => loopBed('zombie-mode.m4a', 800, 0.2, 20.47077);
// — which is the loopEnd, because the decoded buffer is NOT (measured 48.0174 s in Chrome; see the
// padding note in loopBed). Re-encode the file and you must re-measure that number:
// fetch(f).then(r=>r.arrayBuffer()).then(a=>new AudioContext().decodeAudioData(a)).then(b=>b.duration)
export const menuMusic = () => loopBed('background-music.m4a', 30, 0.65, 48);
// THE MENU OWNS THE THEME, AND THE COURT'S BEDS OWN NOTHING WHILE IT IS UP. This is called from
// every stage change, so it is the one place that can say "we are in the menus" — and it has to
// SAY it rather than merely start the bed, because loopBed no-ops once the file is playing. That
// was the bug (Oliver, 2026-08-15: "after some games, or returning to the main menu via the close
// button, the background music does not play any more but you sometimes still hear the court
// background noise"): start() ducked the theme to silence, nothing ever un-ducked it, and the
// court's ambience — which nothing ducked either — carried on underneath the silent menu. Quitting
// a run was the reliable way in, because the death summary is the only path that un-ducked.
export const menuMusic = () => {
loopBed('background-music.m4a', 30, 0.65, 48);
menuDuck(false); // ...and BACK UP, whatever a run left it at
courtDuck(true); // the court is not in the room any more
};
// duck a bed to silence (out=true) or back to its base level over `secs` — the sudden-death duel mutes the music
function fadeBed(file, out, secs = 0.7) {
@@ -191,6 +206,13 @@ export const zombieDuck = out => fadeBed('zombie-mode.m4a', out); // key must ma
// The menu theme is never stopped, only faded — the buffer is already decoded, so fading it back up
// on the death screen costs nothing and can't miss its cue waiting on a re-fetch.
export const menuDuck = out => fadeBed('background-music.m4a', out);
// BOTH court beds as one gesture, because "leaving the court" is one event and the two of them
// have to move together — the menu was silent AND noisy at the same time when only one did.
const courtDuck = out => { fadeBed('tennis-ambient-loop.wav', out); zombieDuck(out); };
// Test surface (test.js), same idea as net.js's netTest. `ducked` is the INTENT — the level a bed
// is at, or will start at if it is still loading — and it is the whole of what the menu-silence bug
// was: a flag nothing ever cleared. Nothing in the game reads this.
export const bedTest = { ducked };
let sysVoice = null;
function say(text, delay = 0) { // fallback when a recorded umpire line is missing
+28 -10
View File
@@ -121,10 +121,17 @@ export const TUNE = {
// ceiling after 105 seconds of splatter, i.e. during game 2 — so shamble speed
// was never a progression lever at all. g12 matches the old top speed (1.75
// m/s), and it climbs to 2.7 m/s by game 22.
zSpeedMax: 2.9, // cap on that ramp (top speed = zSpeed * 3.9). runnerVMax still caps runners.
// THE LATE GAME HAD STOPPED GETTING HARDER (Oliver, 2026-08-15: "it now gets too easy to just
// play through — the splatter gore should be unbelievably hard towards the end"). Every ramp in
// this block used to reach its ceiling by about game 25 and sit there, while the barrel economy
// above kept climbing — so a run got RICHER after game 25 and never more dangerous. The body
// COUNT still caps (zMaxCap is a readability ceiling on a 270 px screen and stays), but what
// those bodies are and how fast they arrive now keeps climbing well past it.
zSpeedMax: 3.6, // cap on that ramp (top speed = zSpeed * 4.6), reached ~game 28 instead of 22
nastyPerGame: 0.03, // share of spawns drawn from the advanced roster, per finished game
nastyMax: 0.75, // ...capped here (g14 matches the old 0.42, g25 hits the cap). The old mix was
// frozen at exactly 0.42 from game 12 to the end of the run.
nastyMax: 1.0, // ...and by game 34 there are NO ordinary shamblers left in the mix at all:
// every body that walks out is off the advanced roster. That is the wall the
// end of a run is supposed to be.
hordeGrow: 0.2, // horde length AND spawn rate both scale by this per set already banked
zHordeCap: 34, // hard body ceiling DURING a horde. A horde ignores the director cap on
// purpose (that is the point of it), but hordeGrow means a late wave now tries
@@ -140,7 +147,11 @@ export const TUNE = {
// so it never crosses your baseline and never scores — it is a standing threat you choose when to
// deal with, which is the target-priority decision the game was missing.
machEvery: 4, // games before the first machine can appear, and games between them after
machMax: 2, // machines allowed on court at once
machMax: 2, // machines allowed on court at once...
machMaxPer: 18, // ...plus one more for every this many games survived, up to machMaxCap. A third
machMaxCap: 4, // and fourth gun is the one kind of late pressure that does NOT crowd the
// ground: they hold position and shoot, so the court stays readable while the
// number of things that can reach you goes up.
machHp: 6, // splat-quality hits to wreck one. It is a steel cart, not a body — and it now
// shows the damage on the way down (z_machw1 dented, z_machw2 split open)
machRoll: 1.6, // m/s it trundles when it repositions. It CANNOT fire while rolling, which is
@@ -427,11 +438,16 @@ export const TUNE = {
dmgBeam: 8, // what it takes off a BOSS. An ordinary body is not damaged by the beam, it is
// DELETED by it (see fireBeam); bosses "must suffer severely" but survive.
// ---- the weapon barrel: the only way to get the four weapons that are not on the duel ladder
barrelEvery: 3, // games between barrels being rolled on (like the ball machine's machEvery)
barrelRamp: 8, // ...and the gap SHRINKS by one for every this many games finished, floored at
// every game (g8 every 2, g16+ every game). Late runs live on the barrels: past
// the point where every tin is capped, fast tapping was the only way left to
// keep up, and "more inventory for strategic use" is the answer to that.
// HALVED, AND FLOORED (Oliver, 2026-08-15: "in splatter gore it now gets too easy to just play
// through, maybe we should reduce the barrels again — pretty early in the game every won game
// gives a barrel"). He is right and the arithmetic says so: at 3/8 the gap hit ONE at game 16 and
// stayed there, so the whole back half of a run handed you a weapon for every game you won, which
// is the opposite of a difficulty curve. The ramp is still there — the late game does need more
// inventory than the early one — it is just slower and it stops.
barrelEvery: 5, // games between barrels being rolled on (like the ball machine's machEvery)
barrelRamp: 14, // ...and the gap SHRINKS by one for every this many games finished
barrelFloor: 3, // ...but never below this. g14 every 4, g28 every 3, and that is as good as it
// ever gets — a barrel is a reward for surviving, never an income.
barrelRoll: 2.6, // m/s it trundles across the far court — fast enough to have to be led
barrelHp: 1, // anything pops it: ball, pellet, bomb, flame, swish. It is a target, not a body
barrelH: 1.15, // drawn height (a body is 1.8)
@@ -671,7 +687,9 @@ export const MODES = [
{ id: 'classic', name: 'CLASSIC TENNIS', sub: 'REAL TENNIS · BEST OF THREE SETS', art: 'mode_classic' },
{ id: 'zombie', name: 'ARCADE', sub: 'ONE GAME PER OPPONENT · BLOOD ONLY', art: 'mode_arcade' },
{ id: 'cup', name: 'THE TOURNAMENT', sub: 'FOUR ROUNDS · THE CHAMPION WAITS', art: 'mode_cup' },
{ id: 'gore', name: 'SPLATTER GORE', sub: 'THE MATCH TURNS · SURVIVE IT', art: 'mode_gore' },
// ...and gore no longer opens on a game of tennis (see gameplay.startGore), so its blurb stopped
// promising one: the match already turned, you just arrived late
{ id: 'gore', name: 'SPLATTER GORE', sub: 'NO WARM-UP · SURVIVE THE DEAD CLUB', art: 'mode_gore' },
];
// THE SETUP SCREEN'S TOP ROW (Oliver's two-step menu, 2026-08-14): three posters, and picking one
+52 -10
View File
@@ -535,6 +535,16 @@ export function cheer(seconds, vol) {
crowd.cheer = Math.max(crowd.cheer, seconds, APPLAUSE_T);
}
// Games between weapon barrels. Exported for the suite, because the shape of this curve IS the
// gore mode's economy and it is one line of arithmetic that once quietly became "one per game".
// The floor is the point: however long a run gets, a barrel stays something you waited for.
export const barrelGap = () => Math.max(TUNE.barrelFloor,
TUNE.barrelEvery - Math.floor(G.runGames / TUNE.barrelRamp));
// ...and how many ball machines the late court is allowed to hold at once. Same idea from the other
// end: the barrels stop growing, this one starts.
export const machCap = () => Math.min(TUNE.machMaxCap,
TUNE.machMax + Math.floor(G.runGames / TUNE.machMaxPer));
export function advancePoint(side) { // shared scoring math; returns whether a game was won
const i = side === 'P' ? 0 : 1, j = 1 - i;
score.pts[i]++;
@@ -574,7 +584,11 @@ export function pointTo(side, reason) { // exported for net.js: a remote verdict
voice(side === 'P' ? 'game_player' : 'game_opponent', 0.7);
cheer(2.8, 0.6);
score.server = other(score.server);
if (G.classic) { G.runGames++; if (classicGame(side)) return; } // ...then a set, then the match
if (G.classic) {
G.runGames++;
if (side === 'P') G.classicWon++; // the number its board ranks
if (classicGame(side)) return; // ...then a set, then the match
}
} else {
voice(scoreKey(), 0.7);
if (side === 'P') cheer(1.2, 0.3);
@@ -612,11 +626,15 @@ export function endClassic(won) {
ball.active = false; G.flash = null; G.modal = null; G.card = null;
if (won) { cheer(4, 1); applause(1, 0.2); voice('unbelievable', 0.4); }
else { aww(1); voice('game_opponent', 0.3); }
// g = GAMES PLAYED, s = SETS YOU WON — this mode's two headline numbers in the record's two slots.
// g = GAMES PLAYED (what the stats screen calls LONGEST MATCH), s = GAMES YOU WON — this mode's
// two headline numbers in the record's two slots. `s` was SETS until the classic board arrived,
// and nothing ever displayed it: the sets line on the death screen is gore's, and read() has
// never looked at `s` at all. Games won is the number worth ranking, because every classic win is
// two sets and a board of nothing but 2s is a wall of ties.
// `dead` carries the RESULT: a match you lost is the run ending badly, a match you won is not,
// so the stats screen's DEATHS column reads as matches lost and (runs − deaths) as matches won,
// with no extra field to keep in step.
const b = endRun(G.runGames, G.sets[0], G.splatT, !won, MODE_CLASSIC);
const b = endRun(G.runGames, G.classicWon, G.splatT, !won, MODE_CLASSIC);
G.best = b; G.newBest = b.newBest;
}
@@ -671,12 +689,12 @@ export function splatPoint(side, quiet = false) {
spawnRacketZombie();
// the rogue ball machine gets wheeled out on the same kind of schedule, capped on court
if (side === 'P' && G.runGames >= TUNE.machEvery && G.runGames % TUNE.machEvery === 0
&& zombies.filter(z => z.variant === 'm').length < TUNE.machMax && !G.bossFight)
&& zombies.filter(z => z.variant === 'm').length < machCap() && !G.bossFight)
spawnMachine();
// ...and a weapon barrel on its own schedule — which TIGHTENS as the run goes on (barrelRamp),
// because the late game is where the rack has to carry you. Still one at a time: two glowing
// pink things on court would out-shout everything the palette uses to say "this one is dangerous".
if (side === 'P' && G.runGames % Math.max(1, TUNE.barrelEvery - Math.floor(G.runGames / TUNE.barrelRamp)) === 0
if (side === 'P' && G.runGames % barrelGap() === 0
&& !zombies.some(z => z.variant === 'o') && !G.bossFight)
spawnBarrel();
// The wave does NOT auto-collapse anymore: the survivors stay and must be cleared BY HAND before
@@ -924,16 +942,40 @@ function transition(dt) {
once('mob', 4.8, () => { spawnZombie(-3.5, -1.6, 'a'); spawnZombie(2.5, -2.1, 'b'); G.shake = { t: 0.5, mag: 6 }; });
once('seated', 5.4, () => voice('remain_seated', 0, 0.95));
once('go', 8.2, () => {
G.mode = 'splatter'; score.server = 'P'; setupPoint(); voice('play', 0.4, 0.9); zombieMusic();
enterSplatter();
G.shake = { t: 0.7, mag: 9 }; // the floor goes out from under the match
// NO modal here anymore — nobody read it (Oliver's testteam, verbatim). The ghost hand teaches
// the pull instead: prime the idle clock so the apron demo starts miming IMMEDIATELY rather
// than after the usual beat of inaction, and it keeps coming back whenever the player stalls.
G.idleHintT = TUNE.pullHintAfter;
G.flash = { text: 'THE MATCH HAS CHANGED', t: 2.0 };
});
}
// WHERE THE SPLATTER MODE ACTUALLY BEGINS. Shared by the transition's last beat and by the gore
// mode's own start, so the two doors cannot land the player in two slightly different games.
// NO modal here — nobody read it (Oliver's testteam, verbatim). The ghost hand teaches the pull
// instead: the idle clock is primed so the apron demo starts miming IMMEDIATELY rather than after
// the usual beat of inaction, and it keeps coming back whenever the player stalls.
function enterSplatter() {
G.mode = 'splatter'; G.everTransitioned = true;
score.server = 'P'; setupPoint();
voice('play', 0.4, 0.9); zombieMusic();
G.idleHintT = TUNE.pullHintAfter;
}
// THE GORE MODE STARTS IN THE GORE (Oliver, 2026-08-15: "the splatter gore should not have the
// normal tennis intro anymore, we should just start right in splatter mode"). The prologue was a
// game of real tennis you had to win before the mode you picked would begin — fine as a reveal the
// first time, a toll booth every time after it. The transition itself is NOT deleted: it is still
// what `forceZombies` plays, it is still the cinematic, and `everTransitioned` above keeps it from
// firing behind us. What is gone is it standing between the menu and the mode.
// The turned opponent still opens the run — he is the man you came to play, and the mode's whole
// premise is that he is already one of them.
export function startGore() {
if (G.mode !== 'tennis') return;
enterSplatter();
spawnZombie(O.x, O.z, 'c'); G.hideO = true;
G.shake = { t: 0.6, mag: 7 };
G.flash = { text: 'THE CLUB IS DEAD', t: 2.0 };
}
export function fault() { // exported for net.js: the receiver's fault call re-arms the server's second serve
if (G.serveNum === 1) {
if (netJudging()) netSendFault(); // netplay: only the first-serve branch wires — the double
+42 -16
View File
@@ -5,7 +5,7 @@
import { TUNE, COURTS, TOURS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, CUP_ROUNDS, matchFoe } from './config.js';
import { P, O, ball, score, G, setupPoint, selectCourt, selectTour } from './state.js';
import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, dragServe, toggleTestMode } from './gameplay.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, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall, startNetVs } from './beats.js';
import { netConnect, netHooks } from './net.js';
import { fitViewport, render, proj, RES, SPRV, LOAD, SPR } from './render.js';
@@ -283,6 +283,12 @@ document.getElementById('brBack').addEventListener('click', () => {
});
document.getElementById('cupGo').addEventListener('click', () => start('cup'));
// 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' ? 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
function summary() {
const t = TOURS.find(x => x.id === pickedTour) || TOURS[0];
@@ -291,9 +297,7 @@ function summary() {
// the mark to beat is the PICKED MODE's own, because the three of them do not measure the same
// thing — see the record-shape note in stats.endRun. The cup banks through endRun as MODE_MATCH
// (see cupWon), so its summary reads the same book it writes.
const mode = m.id === 'zombie' || m.id === 'cup' ? stats.MODE_MATCH
: m.id === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE;
const best = stats.read('all', undefined, mode).longest;
const best = stats.read('all', undefined, boardFor(m.id)).longest;
const unit = m.id === 'zombie' || m.id === 'cup' ? ' FELLED'
: best === 1 ? ' GAME' : ' GAMES';
const name = m.id === 'classic'
@@ -330,6 +334,7 @@ function start(modeId = pickedMode) {
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(); }
// ...and the cup carries the draw the bracket screen just made. Null (the #cup back door) still
// means "roll it yourself", which is what the mode did before the screen existed.
@@ -339,8 +344,7 @@ let stPeriod = 'all', stMode = stats.MODE_GORE;
// 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 = () => {
stMode = pickedMode === 'zombie' || pickedMode === 'cup' ? stats.MODE_MATCH
: pickedMode === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE;
stMode = boardFor(pickedMode);
for (const el of document.getElementById('stModes').children) el.classList.toggle('on', el.dataset.m === stMode);
stats.drawStats(stPeriod, SPRV, stMode); go('stats');
};
@@ -348,17 +352,34 @@ const openStats = () => {
const goMode = () => { buildTops(); go('mode'); };
document.getElementById('mStart').addEventListener('click', goMode);
document.getElementById('mStats').addEventListener('click', openStats);
document.getElementById('mRanks').addEventListener('click', () => { go('ranks'); stats.drawRanks(); });
// 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 kill
// count, on the tap that takes you off the death screen. Anything more often is a form standing
// between you and the next match.
// --- 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.kills.toLocaleString('en-GB')}</b> KILLS · <b>${run.games}</b> GAME${run.games === 1 ? '' : 'S'}`;
`<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;
@@ -376,10 +397,13 @@ document.getElementById('nbSend').addEventListener('click', async () => {
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.kills, nbRun.games);
const r = await stats.submit(name, nbRun.score, nbRun.sub, nbRun.mode);
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); // the board came back with the reply — no second round trip
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.'
@@ -430,8 +454,8 @@ document.getElementById('hot').addEventListener('pointerdown', () => {
// 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() {
if (G.mode === 'tennis') forceZombies();
for (let i = 0; i < 900 && G.mode !== 'splatter'; i++) update(1 / 60);
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;
@@ -580,7 +604,9 @@ const maybeRestart = () => {
// 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();
if (run && run.newKillBest && run.kills > 0) askName(run);
// ...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
if (run && run.newKillBest && run.score > 0) askName(run);
else go('splash');
};
cv.addEventListener('pointerdown', maybeRestart);
+1 -1
View File
@@ -221,7 +221,7 @@ function farB(z) {
// Asset cache-buster — bump with main.js?v=N (see the img.src note below). EXPORTED because the
// menu screens hang <img> tags on the same files, and two copies of this number is one copy too
// many: the one that does not get bumped is the one that serves a cached 404.
export const SPRV = 129;
export const SPRV = 130;
// The boot bar in main.js watches this. Nothing here CHANGES when things load — every sprite was
// already requested at import — it just makes the wait visible and holds the menu back until the
// last sheet has landed. See the onerror note below for the other half of "sometimes missing".
+3
View File
@@ -119,6 +119,9 @@ export const G = {
// a set, TUNE.classicSets sets is the match. It is the FOUNDATION mode, not a warm-up: if the
// core tennis is good, everything built on it is good.
classic: false,
classicWon: 0, // games YOU have taken off the CPU this classic match. G.sets is sets and
// score.games resets every set, so nothing else counted this — and it is what
// the classic world board ranks (a 12-2 win beats a 12-10 one).
done: null, // {won, t} — the run ENDED without a corpse (a classic match played out).
// The one end-of-run state that is not 'eaten': same summary, same gated tap.
bench: null, // THE SPRITE BENCH (back room only): {i, stage, dir} — one opponent on an
+59 -20
View File
@@ -95,6 +95,28 @@ export function multiKill() { run.mk++; }
export const MODE_GORE = 'g', MODE_MATCH = 'z', MODE_CLASSIC = 'c';
const modeOf = r => r.m || MODE_GORE;
// ---- WHAT A RUN IS WORTH, per mode. The world board used to be the gore mode's alone, which made
// it "the leaderboard" for a game that is three games (Oliver, 2026-08-15: "the world rank is still
// about the splatter gore which is not the only thing"). There is now one board per mode, and this
// table is the only place that knows what each one ranks — the three do not share a currency (see
// the record-shape note in endRun), so a single board would be comparing a 300-kill massacre with a
// five-opponent ladder run and calling one of them better.
// score the number ranked, off a run record. Ties break on `sub`, then on age.
// unit what that number IS, printed on the board and in the name prompt
// sub the second column: small, and only ever context for the first
// The RECORD's slots are unchanged, which is why no migration is needed — each mode simply reads
// the slot it has always written: gore's kills, the match's opponents felled (banked in the games
// slot by matchGameWon), classic's games taken off the CPU.
export const BOARDS = {
[MODE_GORE]: { name: 'SPLATTER GORE', score: r => r.k || 0, sub: r => r.g || 0,
unit: 'KILLS', subUnit: 'G', head: 'KILLS · GAMES' },
[MODE_MATCH]: { name: 'ZOMBIE TENNIS', score: r => r.g || 0, sub: r => r.s || 0,
unit: 'FELLED', subUnit: 'G', head: 'FELLED · GAMES' },
[MODE_CLASSIC]: { name: 'CLASSIC', score: r => r.s || 0, sub: r => r.g || 0,
unit: 'GAMES WON', subUnit: 'PLAYED', head: 'WON · PLAYED' },
};
const boardOf = m => BOARDS[m] || BOARDS[MODE_GORE];
export function endRun(games, sets, secs, dead = true, mode = MODE_GORE) {
const all = load();
const prev = all.filter(r => modeOf(r) === mode); // you are only ever compared against your own mode
@@ -102,22 +124,23 @@ export function endRun(games, sets, secs, dead = true, mode = MODE_GORE) {
// TWO bests, because they answer different questions. `newBest` is GAMES and belongs to the death
// summary, which has always celebrated how long you lasted. `newKillBest` is KILLS, which is what
// the world board ranks — so it is the one that earns you a name-entry prompt.
const bestKills = prev.reduce((m, r) => Math.max(m, r.k || 0), 0);
const kills = run.k;
if (banked || (!run.k && !games)) { run = bag(); return { ...best, newBest: false, newKillBest: false }; }
const newBest = games > best.games;
// THE WORLD BOARD IS THE GORE MODE'S. It ranks kills in a run, and a match run has exactly one
// kill per opponent felled — a five-body match would post a "5 kills" row next to a 300-kill
// massacre and mean something completely different by it. No name prompt from a match.
// ponytail: when zombie tennis earns a board of its own it ranks FELLED (with points won as the
// tiebreak — felled alone is a small integer and would be a wall of ties), and that is a server
// change, not this line.
const newKillBest = mode === MODE_GORE && kills > bestKills;
all.push({ t: Date.now(), g: games, s: sets, d: Math.round(secs || 0), m: mode,
k: kills, mk: run.mk, dead: dead ? 1 : 0, z: run.z, w: run.w });
// EVERY MODE HAS A BOARD NOW, so every mode can earn the name prompt — against ITS OWN board's
// number and its own history (BOARDS above), never against the other two. `newKillBest` keeps its
// name because the death screen and main.askName both read it; what it means is "this run beat
// your best on the board it belongs to".
const row = { t: Date.now(), g: games, s: sets, d: Math.round(secs || 0), m: mode,
k: kills, mk: run.mk, dead: dead ? 1 : 0, z: run.z, w: run.w };
const B = boardOf(mode);
const newKillBest = B.score(row) > prev.reduce((m, r) => Math.max(m, B.score(r)), 0);
all.push(row);
save(all); // ...the WHOLE record, not the filtered view — that would delete the other mode
run = bag(); banked = true;
last = { kills, games, newKillBest };
// ...and WHICH board it belongs on, plus the two numbers that board prints, so the name prompt
// and the submission never have to re-derive them from a record shape they do not own
last = { kills, games, newKillBest, mode, score: B.score(row), sub: B.sub(row) };
// `games`/`sets` here are THE BEST RUN'S, which is the only thing a caller wants back — the
// caller already knows what the run it just handed over did. They used to be trailed by the
// ENDED run's own `kills, games`, and the later key won: `G.best.games` was the run that had just
@@ -173,8 +196,8 @@ function myId() {
let last = null;
export function lastRun() { const r = last; last = null; return r; }
export async function board() {
const r = await fetch('api/scores', { cache: 'no-store' });
export async function board(mode = MODE_GORE) {
const r = await fetch('api/scores?m=' + mode, { cache: 'no-store' });
if (!r.ok) throw new Error('board ' + r.status);
return (await r.json()).scores || [];
}
@@ -186,13 +209,15 @@ export async function board() {
export const tidyName = s =>
String(s).toUpperCase().replace(/[^A-Z0-9 ._-]/g, '').trim().slice(0, 12);
export async function submit(rawName, kills, games) {
// `k` is the BOARD's number and `g` its second column — what those mean is the mode's business
// (BOARDS), and the server only ever sorts them. `m` is which board the row goes on.
export async function submit(rawName, k, g, mode = MODE_GORE) {
const name = tidyName(rawName);
if (!name) throw new Error('empty name');
try { localStorage.setItem(NAME_KEY, name); } catch {} // type it once, ever
const r = await fetch('api/scores', { method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ n: name, k: kills, g: games, id: myId() }) });
body: JSON.stringify({ n: name, k, g, m: mode, id: myId() }) });
if (r.status === 409) { const e = new Error('name taken'); e.taken = true; throw e; }
if (!r.ok) throw new Error('submit ' + r.status);
return r.json(); // {ok, rank, scores}
@@ -276,18 +301,23 @@ export function drawStats(period, v, mode = MODE_GORE) {
// The real board. Async, and every failure lands on a line of copy rather than an empty screen —
// the server being down must never be indistinguishable from nobody having played.
export async function drawRanks(rows) {
const el = document.getElementById('rkList');
// THREE BOARDS, one screen, same furniture as the stats screen's three modes — and the column
// heading changes with the tab, because "300" means bodies here, opponents there and games in the
// third and a board that did not say so would be lying by omission.
export async function drawRanks(rows, mode = MODE_GORE) {
const el = document.getElementById('rkList'), B = boardOf(mode);
const hdr = document.getElementById('rkHdr');
if (hdr) hdr.innerHTML = `BEST SINGLE RUN<span>${B.head}</span>`;
if (!rows) {
el.innerHTML = '<p class="none">READING THE BOARD…</p>';
try { rows = await board(); }
try { rows = await board(mode); }
catch { el.innerHTML = '<p class="none">THE BOARD IS UNREACHABLE.</p>'; return; }
}
if (!rows.length) { el.innerHTML = '<p class="none">NOBODY IS ON THE BOARD YET. BE FIRST.</p>'; return; }
if (!rows.length) { el.innerHTML = '<p class="none">NOBODY IS ON THIS BOARD YET. BE FIRST.</p>'; return; }
const me = savedName();
el.innerHTML = rows.map((r, i) =>
`<div class="rkrow${r.n === me ? ' me' : ''}"><b>${n(i + 1)}</b><span>${esc(r.n)}</span>`
+ `<i>${n(r.k || 0)} · ${r.g || 0}G</i></div>`).join('');
+ `<i>${n(r.k || 0)} · ${r.g || 0}</i></div>`).join('');
}
// ------------------------------------------------------------------ check ----
@@ -329,5 +359,14 @@ export function check() {
eq(tidyName('<b>hi</b>'), 'BHIB', 'name strips markup');
eq(tidyName('ABCDEFGHIJKLMNOPQRSTUVWXYZ').length, 12, 'name capped at 12');
eq(tidyName('...'), '...', 'name keeps the allowed punctuation');
// ONE BOARD PER MODE. Each ranks its own currency off the slot its own runs already write, so a
// ladder run and a massacre are never sorted against each other. The fixture above carries three
// gore runs and one match run; the match run is 4 opponents felled over 9 games.
const B = BOARDS;
eq(B.g.score(fx[0]), 10, 'gore ranks kills');
eq(B.z.score(fx[3]), 4, 'zombie tennis ranks opponents felled');
eq(B.z.sub(fx[3]), 9, '...with the games it took as the tiebreak');
eq(B.c.score({ g: 15, s: 12 }), 12, 'classic ranks the games you took off the CPU');
eq(Object.keys(B).sort().join(''), 'cgz', 'three boards, no more and no fewer');
return 'ok';
}