diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..9e83be0
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,12 @@
+# Splatter Tennis — session rules
+
+1. **Read `PROJECT.md` first** — it is the only planning document: how Oliver's feedback is handled,
+ every invariant, every settled feel decision, what is open. Do not re-derive or contradict it.
+2. **`node test.js` must pass before every commit.** It is also a Dockerfile build stage, so a red
+ suite blocks the deploy — but run it locally first. New state-machine behavior gets a test.
+3. **Mode transitions live in `beats.js` only** (duel/horde/boss/changeover). One mode owns the
+ court; every `start*` refuses while another mode runs. A new mode starts there and refuses there.
+4. **Every push deploys** (Coolify → splatter-tennis.wuwi.one). Bump `main.js?v=N` in index.html on
+ every push or phones keep the old build. New top-level files must be added to the Dockerfile COPY.
+5. **Zero dependencies, no build step, no framework.** This is a settled decision — solve it with
+ what is already in the repo.
diff --git a/Dockerfile b/Dockerfile
index bd12467..8249428 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,3 +1,11 @@
+# STAGE 1 — the test gate. `node test.js` drives the real game modules headless; if the state
+# machine breaks, this stage fails, the image never builds, and the deploy never ships. The final
+# stage COPYs the marker so BuildKit cannot skip this stage as unused.
+FROM node:22-alpine AS test
+WORKDIR /app
+COPY *.js /app/
+RUN node test.js && touch /tests-passed
+
FROM python:3.12-alpine
# Was nginx:alpine. The leaderboard needs one process that can WRITE, and nginx is not one.
# Everything the nginx conf was doing here — no-cache so a deploy is picked up on a plain reload,
@@ -5,6 +13,7 @@ FROM python:3.12-alpine
# app — is a few lines in server.py. Standard library only: no pip, no requirements file.
# ponytail: switch to hashed filenames if traffic ever matters.
WORKDIR /app
+COPY --from=test /tests-passed /tmp/tests-passed
COPY index.html manifest.webmanifest *.js server.py /app/
COPY assets /app/assets
# THE SCORES LIVE HERE AND THIS MUST BE A PERSISTENT VOLUME. Without one the json sits in the
diff --git a/HANDOFF.md b/PROJECT.md
similarity index 97%
rename from HANDOFF.md
rename to PROJECT.md
index d3376e1..e863dd5 100644
--- a/HANDOFF.md
+++ b/PROJECT.md
@@ -1,11 +1,16 @@
-# HANDOFF — Splatter Tennis
+# PROJECT — Splatter Tennis
-**This file is the only planning document.** `PLAN.md` and `POLISH-PLAN.md` are deleted —
+**This file is the only planning document** (renamed from `HANDOFF.md`, 2026-08-11 — it was never a
+handoff, it is the project's living notebook). `PLAN.md` and `POLISH-PLAN.md` are deleted —
everything they specified is shipped, and what still matters from them is recorded below. For the
long history, `~/.claude/projects/.../memory/splatter-tennis-history.md` is auto-loaded; for the
detail of any individual change, read the git log.
-Last updated 2026-08-11 (the third freeze: court-ownership guards + `node test.js`). **Start with "How to work on this", "OPEN — leftovers" and "LATER —
+**`node test.js` must pass before any commit** — it drives the real game modules headless and pins
+the state-machine contracts that have historically produced the show-stoppers. The Dockerfile runs
+it as a build stage, so a red suite cannot deploy — but finding out from Coolify is the slow way.
+
+Last updated 2026-08-11 (beats.js extraction, this file renamed, tests gate the build). **Start with "How to work on this", "OPEN — leftovers" and "LATER —
shipping it"** (all three just above the CLOSED sections) — the first tells you how Oliver's feedback
should be handled, the second is everything known to be outstanding in the game, the third is the
demo/store/campaign plan, which is decided but entirely unbuilt. All four playtest rounds are closed
@@ -49,15 +54,19 @@ the primary target.
## Module map (all in repo root)
`config.js` TUNE (live T panel) + structural constants + `UI` palette · `state.js` the `G` object +
P/O/ball/score + `setupPoint`/`sendBoy` · `gameplay.js` ballistics/swings/serve/rules/AI/`update()` +
-transition + `splatPoint` + ball-boy routines · `zombies.js` zombie/gore/particle state +
-`updateZombies` · `render.js` camera/palette-lerp/sprites/all drawing · `input.js` keys/touch/flick ·
-`audio.js` sfx + recorded umpire (`assets/audio/*`, macOS `say -v Daniel`) · `panel.js` the T panel ·
-`main.js` boot, loop, scoreboard test-mode taps, `window.T` test surface (the umpire-tap cheat is
-removed — the testteam found it; `T.forceZombies()` is the console replacement).
-THREE runtime-only import cycles (documented in headers): input↔gameplay, zombies↔gameplay,
-and state↔zombies (`sendBoy` reads `gore` to decide whether a mopping round is worth walking out for).
-All three are safe for the same reason: the imported binding is only ever touched inside a function at
-runtime, never during module evaluation.
+transition + `splatPoint` + ball-boy routines · `beats.js` **the beat machinery: every mode
+transition (duel/horde/boss/changeover/`proceedAfterClear`) — ONE MODE OWNS THE COURT, and each
+`start*` enforces that itself; a new mode starts there and refuses there** · `zombies.js`
+zombie/gore/particle state + `updateZombies` · `render.js` camera/palette-lerp/sprites/all drawing ·
+`input.js` keys/touch/flick · `audio.js` sfx + recorded umpire (`assets/audio/*`, macOS
+`say -v Daniel`) · `panel.js` the T panel · `main.js` boot, loop, scoreboard test-mode taps,
+`window.T` test surface (the umpire-tap cheat is removed — the testteam found it;
+`T.forceZombies()` is the console replacement) · `test.js` the headless regression suite
+(`node test.js`, also a Dockerfile build stage — a red suite cannot deploy).
+FIVE runtime-only import cycles (documented in headers): input↔gameplay, zombies↔gameplay,
+state↔zombies (`sendBoy` reads `gore` to decide whether a mopping round is worth walking out for),
+beats↔gameplay and beats↔zombies. All are safe for the same reason: the imported binding is only
+ever touched inside a function at runtime, never during module evaluation.
## How the game flows
Tennis → win a game → **transition** (`G.mode='transition'`, timeline on `G.transT`) → **splatter**.
@@ -271,12 +280,6 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu
Sizing it the other way round (compute a scale, then set explicit px) laid it out against whatever
`innerHeight` reported at load, before iOS Safari's toolbars had settled, and it stayed a strip short
of the bottom forever after. `100dvh` is what tracks those toolbars.
-- **The swish cooldown scales with how swarmed you are** (`meleeCooldown()` in gameplay.js):
- `meleeCool / (1 + (near-1) * meleeCrowd)`, floored at `meleeCoolMin`, counting bodies within
- `meleeCrowdR`. 1 body 1.00 s, 4 → 0.47, 8 → 0.27, 14+ → 0.22. Oliver died on every game-20 horde
- because the wave grows with `hordeGrow` while the panic button was a flat 1 s; he chose scaling the
- melee over shrinking the horde. **Measure the crowd BEFORE calling `meleeSwipe`** — it kills
- everything in reach, so counting after counts the survivors and the cooldown barely moves.
- **The canvas is deliberately FULL-BLEED under the notch and home indicator; the HUD is what respects
the safe area.** `viewport-fit=cover` is set, and the stands running to the screen edges look right —
but an iPhone's clock was landing directly on the scoreboard. A hidden `#safe` probe div carries
@@ -292,7 +295,7 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu
Widest string checked fits 270px portrait with ~10px spare.
- **Weapons are only ever WON.** `deliverTin()` has exactly one caller: `resolveInterlude('P')`.
The schedule (`TUNE.duelFirst`/`duelEvery`, `G.duelDue`) calls the **duel**, fired from
- `proceedAfterClear()` — the one beat where the court is guaranteed empty. The game beat it
+ `proceedAfterClear()` (beats.js) — the one beat where the court is guaranteed empty. The game beat it
interrupts is parked in `G.afterDuel` and resumed when the duel resolves.
- **Anything that blocks serving.** If a "can't serve" report ever comes in, check these first —
each is an early return in `keyPressed`: `G.mgT`, `G.horde`, `G.changeover`, `G.interlude.introT`,
@@ -376,6 +379,27 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu
`RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud
FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone.
+## Session log: beats.js, the rename, and the build gate (2026-08-11, same day)
+
+Oliver's structural question ("framework? smaller files?") answered with a deliberate NO on both —
+the freezes were never a file-size problem, they were a shared-state interleaving problem — and
+three targeted moves instead:
+- **`beats.js`**: every mode transition extracted from gameplay.js into one file with one stated
+ rule (one mode owns the court; every `start*` enforces it itself). Pure move, zero behavior
+ change, verified by the suite passing untouched before and after.
+- **This file renamed** `HANDOFF.md` → `PROJECT.md` and audited against the code: the duplicated
+ swish-cooldown invariant deduped, the stale umpire-triple-tap comment in gameplay.js corrected
+ (the cheat was removed when the testteam found it), all `HANDOFF` references in code comments
+ updated. `T.step`/`T.press`, `machEvery` 4, `bossEvery` 10 all verified still true.
+- **The Dockerfile gained a test stage** (`node:22-alpine`, `RUN node test.js`): a red suite fails
+ the image build, so Coolify never deploys a build whose state machine is broken. The final stage
+ COPYs the stage's marker file so BuildKit cannot skip it.
+- The suite grew five regressions mined from this file's own history: the pending-horde-onto-boss-
+ stage latch (7803c83), the game-30 EPIC-card clobber, the mgT cross-phase serve lock (e75b388),
+ the duel's payout+heal contract, and the lost-duel set banking with its surge.
+- **`CLAUDE.md` added** (auto-loaded every session): the five rules that keep sessions from getting
+ sloppy — read this file first, run the tests, bump `?v=`, the Dockerfile COPY rule, no deps.
+
## Session log: the third freeze — court ownership enforced, and the first tests (2026-08-11)
Oliver hit the horde freeze a THIRD time, earlier in a run: **a sudden-death duel started inside a
diff --git a/beats.js b/beats.js
new file mode 100644
index 0000000..4787553
--- /dev/null
+++ b/beats.js
@@ -0,0 +1,217 @@
+// THE BEAT MACHINERY — every transition between the modes that can OWN THE COURT lives here:
+// the sudden-death duel (G.interlude), the horde (G.horde), the chapter boss (G.bossFight), the
+// changeover breather (G.changeover), and the clear-advance that hands play from one to the next
+// (proceedAfterClear). THE RULE: one mode owns the court at a time, and every start* function
+// enforces that itself — a caller never has to know what else is running. All three field freezes
+// came from two of these overlapping; if you add a mode, it starts here and it refuses here.
+// (beats <-> gameplay and beats <-> zombies are runtime-only import cycles, same pattern as
+// input <-> gameplay: the imported bindings are only touched inside functions, never at load.)
+
+import { TUNE, UI, bossArt, COURT, HOME } from './config.js';
+import { G, P, ball, score, setupPoint, sendBoy } from './state.js';
+import { voice, zombieDuck, aww } from './audio.js';
+import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie } from './zombies.js';
+import { cheer, aside, endBurst, deliverTin, showModal, logSwing } from './gameplay.js';
+
+// the court is mopped up (or was already clear): play advances to whatever the won game earned —
+// a changeover breather, a plain even-game beat, or (on a set) the centered SET card then a horde.
+export function proceedAfterClear() {
+ G.clearing = false;
+ const mode = G.afterClear, side = G.clearWinner;
+ G.afterClear = null;
+ // A clear is the ONE moment the court is guaranteed empty — the natural slot for the scheduled
+ // duel. (A wall-clock timer would need its own "wait for a safe moment" and could fire mid-swarm.)
+ // The chapter boss outranks everything here, including a set: a boss walking on into a standing
+ // wave (or into a horde) would be unreadable. Like the duel it parks the interrupted beat.
+ if (G.bossDue) { const c = G.bossDue; G.bossDue = 0; G.afterDuel = mode || 'game'; startBossFight(c); return; }
+ // The set/horde beat outranks the duel; the duel just waits for the next clear.
+ if (mode !== 'set' && G.duelDue) { G.duelDue = false; G.afterDuel = mode || 'game'; startDuel(); return; }
+ if (mode === 'set') {
+ G.card = { title: 'SET ' + (G.sets[0] + G.sets[1]), // the set just banked (sets already ++'d at game over)
+ sub1: side === 'P' ? 'THE SET IS YOURS' : 'SET TO THEM',
+ color: side === 'P' ? UI.pick : UI.hot, t: TUNE.setCardT };
+ G.pendingHorde = true; // the horde begins when the card's pause ends
+ ball.active = false; G.phase = 'over'; G.overT = TUNE.setCardT;
+ } else if (mode === 'changeover') {
+ startChangeover();
+ } else { // 'game': even-game beat, then the next wave arms
+ ball.active = false; G.phase = 'over'; G.overT = TUNE.gameBeatT;
+ }
+ // (no 'GAME N' flash on either path anymore — the bar under the scoreboard prints the game
+ // number permanently, and the flash was the same news shouted a second time)
+}
+
+// changeover (after odd games, as the rules demand): a forced breather — no spawns,
+// no serving, the boys mop. Zombies already on court keep walking; it's calm, not safe.
+function startChangeover() {
+ G.changeover = TUNE.changeoverT; // seconds left, not a flag: "Time." is called when THIS runs out
+ G.phase = 'over'; G.overT = TUNE.changeoverT;
+ // out with the mop, across the near court where the gore is — NOT to the ball: a dead ball
+ // parked off the field (sendBoy's z guard) meant no boy came out at all, and the breather is
+ // now long enough that an empty court reads as the game having stalled.
+ sendBoy(0, COURT.L * 0.75, true); // near enough the camera to actually watch him work
+ // no CHANGEOVER flash — the countdown panel says 'CHANGEOVER · RESUMING IN n' for the whole
+ // breather (render), so the flash was the same word with a shorter lifespan
+ aside('ump_still_a_match', 2.6); // the breather is when he reminds you this is a match
+ // no tins here anymore — weapons are won in the duel, never handed out on a schedule
+}
+
+// ---- the interlude: order is restored, one sudden-death point vs the racket zombie.
+// Tennis rules apply for exactly one point — the absurdity peak: the umpire never stopped watching.
+// This is now the ONLY source of weapons in the game: win the point or leave empty-handed.
+export function startDuel() {
+ // Hard mutual exclusion, HERE, where every caller routes through: a duel must never begin while
+ // another control mode owns the court. Each of these has ended a run when they overlapped.
+ if (G.interlude || G.horde || G.bossFight) return;
+ if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // the schedule guarantees an opponent
+ for (const z of zombies) if (z.variant === 'r') z.dueled = true; // a veteran can be challenged again — this marks the fresh duel
+ G.weapon = 0; // tennis rules for the duel: plain balls, no tins
+ G.mgT = 0; endBurst(); pellets.length = 0; // ...and a burst in progress is over. The umpire has restored order.
+ G.interlude = { introT: 2.4 }; // the intro beat: serving blocked while the umpire announces
+ G.boy = null; // ...and the mop boy is waved off the court (sendBoy is barred for the duration)
+ ball.active = false; setupPoint();
+ zombieDuck(true); // fade the zombie music out for the 1:1 duel
+ voice('play_resume', 0.2); voice('sudden_death', 2.4);
+ if (!G.seen.duel) { // the rules change back — worth one real interruption, once per run
+ G.seen.duel = true;
+ showModal({ title: 'SUDDEN DEATH', color: UI.hot,
+ body: ["IT'S TENNIS AGAIN — ONE POINT", 'WIN IT AND THEY HAND YOU A WEAPON'] });
+ } else G.flash = { text: 'SUDDEN DEATH', t: 1.6 };
+}
+
+// ---- the chapter boss: every TUNE.bossEvery games, the court is cleared and ONE thing walks on.
+// No other spawns until it is down, so the fight is legible and so banking a bomb for it is a real
+// decision (which is the point — tins arrive every 3 games, so there was never a reason to save one).
+// It threatens BOTH channels: its volleys chip your hp, and letting it reach you is the ordinary
+// instant death. G.bossFight holds the chapter number while it runs.
+export function startBossFight(chapter) {
+ if (G.bossFight) return;
+ G.bossFight = chapter;
+ zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; // a clean stage
+ G.horde = null; G.mgT = 0; endBurst(); G.clearing = false;
+ ball.active = false; setupPoint();
+ spawnChapterBoss(chapter);
+ zombieDuck(true); // the music drops out for it
+ voice('ump_boss_arrives', 0, 0.9); voice('sudden_death', 2.6, 0.85);
+ const art = bossArt(chapter);
+ G.card = { title: art.name, color: UI.bad, t: TUNE.setCardT };
+ G.flash = null;
+ // (the four-line machine instruction modal is gone — people figure it out)
+}
+
+// the chapter boss is down: the parked game beat resumes and normal spawning comes back
+export function endBossFight() {
+ if (!G.bossFight) return;
+ const c = G.bossFight; G.bossFight = 0;
+ zombieDuck(false);
+ shots.length = 0;
+ cheer(3.4, 0.9); voice('unbelievable', 0.3);
+ // hand back the game beat the boss interrupted FIRST — same contract resolveInterlude uses. It
+ // used to run LAST, and its 'set' branch writes a routine SET card and a short overT: on a game
+ // that is both a set and a boss chapter (game 30), the EPIC card below was overwritten before it
+ // had drawn a single frame, and the beat was cut to setCardT. The boss card wins now.
+ if (G.afterDuel) { G.afterClear = G.afterDuel; G.afterDuel = null; proceedAfterClear(); }
+ // The longest fight in the game deserves more than the two seconds a routine set gets. Big word,
+ // long hold — the chapter and the crowd move down to the subs where they belong.
+ // (no 'GAME n NEXT' line: the bar under the scoreboard prints the game number permanently, and a
+ // card that repeats what the HUD already says is the clutter this pass is meant to remove.)
+ G.card = { title: 'EPIC!', sub1: 'YOU DID IT!', color: UI.pick, t: TUNE.bossWinT,
+ wait: TUNE.bossDeathT }; // ...after the body has finished coming apart
+ ball.active = false;
+ // a duel the handback just started owns the phase; otherwise hold the boss beat, and never
+ // SHORTEN a longer one the handback set (a changeover's 5 s outlasts this)
+ if (!G.interlude) { G.phase = 'over'; G.overT = Math.max(G.overT, TUNE.bossWinT + TUNE.bossDeathT); }
+}
+
+// ---- THE DEADLOCK WATCHDOG.
+// A run that hard-locks costs the player everything, and this file has form: `mgT` was once left
+// above zero by a beat that moved the phase underneath it, and `mgT > 0` blocks serving, so the
+// game sat there with the gun racket on screen. This is the net.
+//
+// It cannot misfire on a legitimate wait, and that is the whole design: every legitimate reason you
+// cannot serve — a horde, a burst, a changeover, the duel's announcement, an 'over' beat — has a
+// CLOCK counting down toward letting you act. So the test is not "how long has the player been
+// blocked" (a horde blocks you for fifteen honest seconds); it is "is the thing that should be
+// releasing me actually moving". If none of those numbers changes for stuckT seconds while the
+// player is blocked and no popup is waiting on them, the state is dead and nothing will revive it.
+//
+// It also LOGS what was frozen, into the same miss log the scoreboard tap already shows — a lock
+// that unsticks itself and leaves no evidence is worse than one that does not.
+let stuckT = 0, stuckSig = '';
+export function watchdog(dt) {
+ if (G.mode !== 'splatter' || G.modal) { stuckT = 0; return; } // a popup waits on the PLAYER: legitimate
+ // INVARIANT NET, before the clock-based test — because the clock-based test is blind to exactly
+ // this: a horde's own timer keeps ticking, so the sig always "moves" while the player is welded
+ // in place. Two states the guards above should make impossible; if a path still reaches them,
+ // repair on the spot and leave evidence in the miss log.
+ if (G.horde && G.interlude) { // a duel inside a horde: no steering, no serving, spawns keep coming
+ logSwing({ result: 'INVARIANT', reason: 'duel active during horde — interlude dropped, duel re-scheduled' });
+ G.interlude = false; G.duelDue = true; G.duelLerp = 0;
+ }
+ if (G.horde && G.phase !== 'rally') { // pretoss under a horde = the finger steers nothing
+ logSwing({ result: 'INVARIANT', reason: `phase '${G.phase}' during horde — forced back to rally`, phase: G.phase });
+ G.phase = 'rally'; ball.active = false;
+ P.pose = 'idle'; P.poseT = 0; P.swingT = -1;
+ }
+ const blocked = G.horde || G.mgT > 0 || G.changeover || G.phase === 'over'
+ || (G.interlude && G.interlude.introT > 0)
+ || (G.phase === 'toss' && ball.y < 2.0); // a toss on the floor: only a held aim keeps it there
+ if (!blocked) { stuckT = 0; return; }
+ const sig = [G.horde && G.horde.t, G.mgT, G.changeover, G.interlude && G.interlude.introT,
+ G.phase === 'over' && G.overT, ball.y].map(v => (+v || 0).toFixed(2)).join('|');
+ if (sig !== stuckSig) { stuckSig = sig; stuckT = 0; return; } // something is still moving — fine
+ if ((stuckT += dt) < TUNE.stuckT) return;
+ logSwing({ result: 'UNSTUCK', reason: `nothing moved for ${TUNE.stuckT}s — released a dead latch`,
+ phase: G.phase, sig, aim: !!G.aim });
+ G.horde = null; G.mgT = 0; G.changeover = false; G.interlude = false;
+ G.aim = null; endBurst(); setupPoint();
+ stuckT = 0; stuckSig = '';
+}
+
+export function resolveInterlude(side) {
+ if (!G.interlude) return;
+ G.interlude = false;
+ zombieDuck(false); // duel over — bring the music back up
+ ball.active = false; G.phase = 'over'; G.overT = 2.2;
+ if (side === 'P') {
+ cheer(3, 0.7);
+ deliverTin(); // win the point, win the next tin
+ // ...and the trainer comes out. THE reason to want a duel rather than merely tolerate it — it is
+ // the only healing in the game, and blood is the only thing you cannot buy back otherwise. It
+ // gets its OWN popup (queued behind the weapon), with the red cross over it: folded in as a line
+ // of body text under a weapon icon it read as a footnote to the tin rather than as the prize.
+ if (G.hp < G.hpMax) {
+ G.hp = Math.min(G.hpMax, G.hp + TUNE.healPerDuel);
+ voice('ump_trainer', 0.2, 0.95);
+ showModal({ title: 'WOUNDS TREATED', icon: 'x_cross', iconH: 52, color: UI.pick,
+ body: ['THE TRAINER PATCHED YOU UP', G.hp + ' OF ' + G.hpMax + ' BACK'] });
+ }
+ } else {
+ voice('game_opponent', 0.4); aww(0.85); // lost the sudden-death duel
+ score.games[1]++; G.runGames++; // it goes on the scoreboard
+ if (score.games[1] >= 6) { G.sets[1]++; score.games = [0, 0]; }
+ G.flash = { text: 'GAME. THEM.', t: 1.6 };
+ for (let k = 0; k < 5; k++) // and the dead lose their patience: an instant surge
+ spawnZombie((Math.random() * 2 - 1) * 5, -1.5 - Math.random() * 2, Math.random() < 0.5 ? 'a' : 'b');
+ }
+ if (G.afterDuel) { // a scheduled duel interrupted a game beat — hand it back now that it's settled
+ G.afterClear = G.afterDuel; G.afterDuel = null;
+ proceedAfterClear();
+ }
+}
+
+// the swarm keeps coming: no serving, the racket auto-fires, the thumb drag steers you
+export function startHorde() {
+ if (G.horde || G.interlude || G.bossFight) return; // same contract as startDuel: one mode owns the court
+ G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked
+ const grow = 1 + (G.sets[0] + G.sets[1]) * TUNE.hordeGrow; // each set banked makes the next wave worse
+ // the gun grows WITH the wave: spawn rate and length both scale by grow, so a flat gun fell
+ // further behind on every set banked (the second horde was where every run ended)
+ G.horde = { t: TUNE.hordeLen * grow, spawnT: 0, mgT: 0, rate: TUNE.hordeRate * grow, mgRate: TUNE.mgRate * grow };
+ ball.active = false; G.phase = 'rally'; // ball gone; 'rally' lets the movement block steer P
+ P.pose = 'idle'; P.poseT = 0; P.swingT = -1; // clear the triggering shot's serve/swing pose
+ P.z = HOME.P.z; // brace at the baseline — the horde is a hold-the-line stand (finger steers x only)
+ aside('ump_spectators', 0.4); // a horde IS the crowd on the court. (The near-identical 'remain seated'
+ // that used to play right before it belongs to the transition, not here.)
+ G.flash = { text: 'THEY KEEP COMING', t: 1.2 };
+}
diff --git a/config.js b/config.js
index cb4cb9e..d593bfd 100644
--- a/config.js
+++ b/config.js
@@ -46,7 +46,7 @@ export const TUNE = {
// ---- THE DIFFICULTY DIRECTOR. Every dial below used to bottom out by about game 12, which is why
// a 50-game run stopped changing (games 12..50 were arithmetically identical). They are all keyed
// on G.runGames now and all keep moving; the ceilings that remain exist for perf or fairness, not
- // because the curve ran out. See HANDOFF "The difficulty director".
+ // because the curve ran out. See PROJECT.md "The difficulty director".
// Calibrated to TRACK the old curve through roughly game 12 and then keep going, rather than to
// replace it: the first pass at this made games 1-30 easier, which is the opposite of the ask.
zSpawnEvery: 3.0, // s between spawns in game 1
@@ -70,7 +70,7 @@ export const TUNE = {
// for ~120 spawns where the old fixed 12 s x 2.5/s could only ever ask for 30,
// and a 40-body court on a 270px-wide portrait screen is mush, not menace.
// ---- PLAYER HEALTH. A SECOND, SEPARATE damage channel: only ranged attacks touch it. A shambler
- // reaching you is still instant permadeath (G.mode='eaten') — that decision is settled, see HANDOFF.
+ // reaching you is still instant permadeath (G.mode='eaten') — that decision is settled, see PROJECT.md.
// There is no HP bar and there never will be: the state shows as blood on the man.
playerHp: 4, // hits from ranged attacks you can absorb before you go down
hurtGrace: 1.1, // s of invulnerability after a hit — a burst must not delete you in one volley
diff --git a/gameplay.js b/gameplay.js
index 4dd1517..74a1c6c 100644
--- a/gameplay.js
+++ b/gameplay.js
@@ -6,6 +6,7 @@ import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zom
import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js';
import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies
import { updateZombies, updateParts, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel , fireBeam , pops, popText } from './zombies.js';
+import { proceedAfterClear, startDuel, startHorde, resolveInterlude, watchdog } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing
// ------------------------------------------------------------- ballistics ----
// Launch ball from (x0,y0,z0) so it arrives at (tx, tz) with horizontal speed s under gravity g.
@@ -529,48 +530,8 @@ export function splatPoint(side, quiet = false) {
}
}
-// the court is mopped up (or was already clear): play advances to whatever the won game earned —
-// a changeover breather, a plain even-game beat, or (on a set) the centered SET card then a horde.
-export function proceedAfterClear() {
- G.clearing = false;
- const mode = G.afterClear, side = G.clearWinner;
- G.afterClear = null;
- // A clear is the ONE moment the court is guaranteed empty — the natural slot for the scheduled
- // duel. (A wall-clock timer would need its own "wait for a safe moment" and could fire mid-swarm.)
- // The chapter boss outranks everything here, including a set: a boss walking on into a standing
- // wave (or into a horde) would be unreadable. Like the duel it parks the interrupted beat.
- if (G.bossDue) { const c = G.bossDue; G.bossDue = 0; G.afterDuel = mode || 'game'; startBossFight(c); return; }
- // The set/horde beat outranks the duel; the duel just waits for the next clear.
- if (mode !== 'set' && G.duelDue) { G.duelDue = false; G.afterDuel = mode || 'game'; startDuel(); return; }
- if (mode === 'set') {
- G.card = { title: 'SET ' + (G.sets[0] + G.sets[1]), // the set just banked (sets already ++'d at game over)
- sub1: side === 'P' ? 'THE SET IS YOURS' : 'SET TO THEM',
- color: side === 'P' ? UI.pick : UI.hot, t: TUNE.setCardT };
- G.pendingHorde = true; // the horde begins when the card's pause ends
- ball.active = false; G.phase = 'over'; G.overT = TUNE.setCardT;
- } else if (mode === 'changeover') {
- startChangeover();
- } else { // 'game': even-game beat, then the next wave arms
- ball.active = false; G.phase = 'over'; G.overT = TUNE.gameBeatT;
- }
- // (no 'GAME N' flash on either path anymore — the bar under the scoreboard prints the game
- // number permanently, and the flash was the same news shouted a second time)
-}
-
-// changeover (after odd games, as the rules demand): a forced breather — no spawns,
-// no serving, the boys mop. Zombies already on court keep walking; it's calm, not safe.
-function startChangeover() {
- G.changeover = TUNE.changeoverT; // seconds left, not a flag: "Time." is called when THIS runs out
- G.phase = 'over'; G.overT = TUNE.changeoverT;
- // out with the mop, across the near court where the gore is — NOT to the ball: a dead ball
- // parked off the field (sendBoy's z guard) meant no boy came out at all, and the breather is
- // now long enough that an empty court reads as the game having stalled.
- sendBoy(0, COURT.L * 0.75, true); // near enough the camera to actually watch him work
- // no CHANGEOVER flash — the countdown panel says 'CHANGEOVER · RESUMING IN n' for the whole
- // breather (render), so the flash was the same word with a shorter lifespan
- aside('ump_still_a_match', 2.6); // the breather is when he reminds you this is a match
- // no tins here anymore — weapons are won in the duel, never handed out on a schedule
-}
+// (proceedAfterClear and every start*/end* mode transition live in beats.js — one file owns who
+// has the court. splatPoint above only ARMS the schedule latches; beats.js decides when they fire.)
// ---- the courtside rack. RACK_MAX + 1 slots, and only slot 0 is fixed: it is BALL (infinite), or
// an overflow weapon riding it temporarily. Slots 1..RACK_MAX are what you have picked up, in order.
@@ -708,149 +669,8 @@ function tickTestMode(dt) {
stockTestRack(); // no barrels: the rack is already full of everything they would hand you, and
} // one rolling across the sandbox was just something else to look at
-// ---- the interlude: order is restored, one sudden-death point vs the racket zombie.
-// Tennis rules apply for exactly one point — the absurdity peak: the umpire never stopped watching.
-// This is now the ONLY source of weapons in the game: win the point or leave empty-handed.
-export function startDuel() {
- // Hard mutual exclusion, HERE, where every caller routes through: a duel must never begin while
- // another control mode owns the court. Each of these has ended a run when they overlapped.
- if (G.interlude || G.horde || G.bossFight) return;
- if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // the schedule guarantees an opponent
- for (const z of zombies) if (z.variant === 'r') z.dueled = true; // a veteran can be challenged again — this marks the fresh duel
- G.weapon = 0; // tennis rules for the duel: plain balls, no tins
- G.mgT = 0; endBurst(); pellets.length = 0; // ...and a burst in progress is over. The umpire has restored order.
- G.interlude = { introT: 2.4 }; // the intro beat: serving blocked while the umpire announces
- G.boy = null; // ...and the mop boy is waved off the court (sendBoy is barred for the duration)
- ball.active = false; setupPoint();
- zombieDuck(true); // fade the zombie music out for the 1:1 duel
- voice('play_resume', 0.2); voice('sudden_death', 2.4);
- if (!G.seen.duel) { // the rules change back — worth one real interruption, once per run
- G.seen.duel = true;
- showModal({ title: 'SUDDEN DEATH', color: UI.hot,
- body: ["IT'S TENNIS AGAIN — ONE POINT", 'WIN IT AND THEY HAND YOU A WEAPON'] });
- } else G.flash = { text: 'SUDDEN DEATH', t: 1.6 };
-}
-
-// ---- the chapter boss: every TUNE.bossEvery games, the court is cleared and ONE thing walks on.
-// No other spawns until it is down, so the fight is legible and so banking a bomb for it is a real
-// decision (which is the point — tins arrive every 3 games, so there was never a reason to save one).
-// It threatens BOTH channels: its volleys chip your hp, and letting it reach you is the ordinary
-// instant death. G.bossFight holds the chapter number while it runs.
-export function startBossFight(chapter) {
- if (G.bossFight) return;
- G.bossFight = chapter;
- zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; // a clean stage
- G.horde = null; G.mgT = 0; endBurst(); G.clearing = false;
- ball.active = false; setupPoint();
- spawnChapterBoss(chapter);
- zombieDuck(true); // the music drops out for it
- voice('ump_boss_arrives', 0, 0.9); voice('sudden_death', 2.6, 0.85);
- const art = bossArt(chapter);
- G.card = { title: art.name, color: UI.bad, t: TUNE.setCardT };
- G.flash = null;
- // (the four-line machine instruction modal is gone — people figure it out)
-}
-
-// the chapter boss is down: the parked game beat resumes and normal spawning comes back
-export function endBossFight() {
- if (!G.bossFight) return;
- const c = G.bossFight; G.bossFight = 0;
- zombieDuck(false);
- shots.length = 0;
- cheer(3.4, 0.9); voice('unbelievable', 0.3);
- // hand back the game beat the boss interrupted FIRST — same contract resolveInterlude uses. It
- // used to run LAST, and its 'set' branch writes a routine SET card and a short overT: on a game
- // that is both a set and a boss chapter (game 30), the EPIC card below was overwritten before it
- // had drawn a single frame, and the beat was cut to setCardT. The boss card wins now.
- if (G.afterDuel) { G.afterClear = G.afterDuel; G.afterDuel = null; proceedAfterClear(); }
- // The longest fight in the game deserves more than the two seconds a routine set gets. Big word,
- // long hold — the chapter and the crowd move down to the subs where they belong.
- // (no 'GAME n NEXT' line: the bar under the scoreboard prints the game number permanently, and a
- // card that repeats what the HUD already says is the clutter this pass is meant to remove.)
- G.card = { title: 'EPIC!', sub1: 'YOU DID IT!', color: UI.pick, t: TUNE.bossWinT,
- wait: TUNE.bossDeathT }; // ...after the body has finished coming apart
- ball.active = false;
- // a duel the handback just started owns the phase; otherwise hold the boss beat, and never
- // SHORTEN a longer one the handback set (a changeover's 5 s outlasts this)
- if (!G.interlude) { G.phase = 'over'; G.overT = Math.max(G.overT, TUNE.bossWinT + TUNE.bossDeathT); }
-}
-
-// ---- THE DEADLOCK WATCHDOG.
-// A run that hard-locks costs the player everything, and this file has form: `mgT` was once left
-// above zero by a beat that moved the phase underneath it, and `mgT > 0` blocks serving, so the
-// game sat there with the gun racket on screen. This is the net.
-//
-// It cannot misfire on a legitimate wait, and that is the whole design: every legitimate reason you
-// cannot serve — a horde, a burst, a changeover, the duel's announcement, an 'over' beat — has a
-// CLOCK counting down toward letting you act. So the test is not "how long has the player been
-// blocked" (a horde blocks you for fifteen honest seconds); it is "is the thing that should be
-// releasing me actually moving". If none of those numbers changes for stuckT seconds while the
-// player is blocked and no popup is waiting on them, the state is dead and nothing will revive it.
-//
-// It also LOGS what was frozen, into the same miss log the scoreboard tap already shows — a lock
-// that unsticks itself and leaves no evidence is worse than one that does not.
-let stuckT = 0, stuckSig = '';
-function watchdog(dt) {
- if (G.mode !== 'splatter' || G.modal) { stuckT = 0; return; } // a popup waits on the PLAYER: legitimate
- // INVARIANT NET, before the clock-based test — because the clock-based test is blind to exactly
- // this: a horde's own timer keeps ticking, so the sig always "moves" while the player is welded
- // in place. Two states the guards above should make impossible; if a path still reaches them,
- // repair on the spot and leave evidence in the miss log.
- if (G.horde && G.interlude) { // a duel inside a horde: no steering, no serving, spawns keep coming
- logSwing({ result: 'INVARIANT', reason: 'duel active during horde — interlude dropped, duel re-scheduled' });
- G.interlude = false; G.duelDue = true; G.duelLerp = 0;
- }
- if (G.horde && G.phase !== 'rally') { // pretoss under a horde = the finger steers nothing
- logSwing({ result: 'INVARIANT', reason: `phase '${G.phase}' during horde — forced back to rally`, phase: G.phase });
- G.phase = 'rally'; ball.active = false;
- P.pose = 'idle'; P.poseT = 0; P.swingT = -1;
- }
- const blocked = G.horde || G.mgT > 0 || G.changeover || G.phase === 'over'
- || (G.interlude && G.interlude.introT > 0)
- || (G.phase === 'toss' && ball.y < 2.0); // a toss on the floor: only a held aim keeps it there
- if (!blocked) { stuckT = 0; return; }
- const sig = [G.horde && G.horde.t, G.mgT, G.changeover, G.interlude && G.interlude.introT,
- G.phase === 'over' && G.overT, ball.y].map(v => (+v || 0).toFixed(2)).join('|');
- if (sig !== stuckSig) { stuckSig = sig; stuckT = 0; return; } // something is still moving — fine
- if ((stuckT += dt) < TUNE.stuckT) return;
- logSwing({ result: 'UNSTUCK', reason: `nothing moved for ${TUNE.stuckT}s — released a dead latch`,
- phase: G.phase, sig, aim: !!G.aim });
- G.horde = null; G.mgT = 0; G.changeover = false; G.interlude = false;
- G.aim = null; endBurst(); setupPoint();
- stuckT = 0; stuckSig = '';
-}
-
-export function resolveInterlude(side) {
- if (!G.interlude) return;
- G.interlude = false;
- zombieDuck(false); // duel over — bring the music back up
- ball.active = false; G.phase = 'over'; G.overT = 2.2;
- if (side === 'P') {
- cheer(3, 0.7);
- deliverTin(); // win the point, win the next tin
- // ...and the trainer comes out. THE reason to want a duel rather than merely tolerate it — it is
- // the only healing in the game, and blood is the only thing you cannot buy back otherwise. It
- // gets its OWN popup (queued behind the weapon), with the red cross over it: folded in as a line
- // of body text under a weapon icon it read as a footnote to the tin rather than as the prize.
- if (G.hp < G.hpMax) {
- G.hp = Math.min(G.hpMax, G.hp + TUNE.healPerDuel);
- voice('ump_trainer', 0.2, 0.95);
- showModal({ title: 'WOUNDS TREATED', icon: 'x_cross', iconH: 52, color: UI.pick,
- body: ['THE TRAINER PATCHED YOU UP', G.hp + ' OF ' + G.hpMax + ' BACK'] });
- }
- } else {
- voice('game_opponent', 0.4); aww(0.85); // lost the sudden-death duel
- score.games[1]++; G.runGames++; // it goes on the scoreboard
- if (score.games[1] >= 6) { G.sets[1]++; score.games = [0, 0]; }
- G.flash = { text: 'GAME. THEM.', t: 1.6 };
- for (let k = 0; k < 5; k++) // and the dead lose their patience: an instant surge
- spawnZombie((Math.random() * 2 - 1) * 5, -1.5 - Math.random() * 2, Math.random() < 0.5 ? 'a' : 'b');
- }
- if (G.afterDuel) { // a scheduled duel interrupted a game beat — hand it back now that it's settled
- G.afterClear = G.afterDuel; G.afterDuel = null;
- proceedAfterClear();
- }
-}
+// (startDuel, startBossFight/endBossFight, the deadlock watchdog and resolveInterlude live in
+// beats.js — see its header for the one-owner rule.)
// retire the splatter ball; a running changeover's long beat must not be clobbered short
export function spentBall() {
@@ -884,23 +704,8 @@ export function dodge(dir) {
const xVisible = () => Math.min(P_BOUNDS.x, (W / 2 - 14) / (TUNE.camF / (TUNE.camZ - P.z)));
const xLimit = () => Math.max(xVisible(), COURT.W / 2);
-// the swarm keeps coming: no serving, the racket auto-fires, the thumb drag steers you
-function startHorde() {
- if (G.horde || G.interlude || G.bossFight) return; // same contract as startDuel: one mode owns the court
- G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked
- const grow = 1 + (G.sets[0] + G.sets[1]) * TUNE.hordeGrow; // each set banked makes the next wave worse
- // the gun grows WITH the wave: spawn rate and length both scale by grow, so a flat gun fell
- // further behind on every set banked (the second horde was where every run ended)
- G.horde = { t: TUNE.hordeLen * grow, spawnT: 0, mgT: 0, rate: TUNE.hordeRate * grow, mgRate: TUNE.mgRate * grow };
- ball.active = false; G.phase = 'rally'; // ball gone; 'rally' lets the movement block steer P
- P.pose = 'idle'; P.poseT = 0; P.swingT = -1; // clear the triggering shot's serve/swing pose
- P.z = HOME.P.z; // brace at the baseline — the horde is a hold-the-line stand (finger steers x only)
- aside('ump_spectators', 0.4); // a horde IS the crowd on the court. (The near-identical 'remain seated'
- // that used to play right before it belongs to the transition, not here.)
- G.flash = { text: 'THEY KEEP COMING', t: 1.2 };
-}
+// (startHorde lives in beats.js)
-// hidden shortcut: tapping the umpire three times summons the horror early
// Back to a fresh run WITHOUT reloading the page. The reload was the honest reset, but it also tore
// down the AudioContext, so the menu theme restarted from silence every time you died — and the menu
// is supposed to pick up the track that was already playing over the death summary. Every mutable
@@ -1048,7 +853,7 @@ function intercept(side) {
// miss reason is drawn on the HUD (TUNE.missHud).
const r2 = x => Math.round(x * 100) / 100;
-function logSwing(entry) {
+export function logSwing(entry) { // exported for beats.js (the watchdog logs into the same ring)
entry.at = r2(G.idleT);
G.missLog.push(entry);
if (G.missLog.length > 50) G.missLog.shift();
diff --git a/index.html b/index.html
index 037da56..6536780 100644
--- a/index.html
+++ b/index.html
@@ -398,6 +398,6 @@
-
+