diff --git a/HANDOFF.md b/HANDOFF.md
index 53b6cc3..d3376e1 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -5,7 +5,7 @@ everything they specified is shipped, and what still matters from them is record
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-08 (testgroup round: popup policy, ghost hand, barrel economy). **Start with "How to work on this", "OPEN — leftovers" and "LATER —
+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 —
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
@@ -376,6 +376,30 @@ 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: 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
+live horde.** Welded on the right side, no drag (duel's `pretoss` kills steering), no serve
+(`G.horde` blocks taps), spawns kept coming. The full chain: kills still score during the SET card
+beat (spawn director keeps running there), a game won mid-card re-armed `G.clearing`/`G.afterClear`
+while `pendingHorde` was already latched → horde started with a clear parked → mid-horde the gun
+mowed the court to an empty frame between spawns → `G.clearing && !blockers` advanced the beat
+**mid-horde** → `duelDue` fired `startDuel()`. The watchdog was blind to it by design: its signature
+includes `horde.t`, which ticks every frame, so the state always looked alive.
+
+Fixed at the chokepoints, not the call sites:
+- `update()`'s hand-clear advance now requires `!G.horde` — a latched clear waits for `endHorde`.
+- `startDuel()` and `startHorde()` hard-refuse while any other mode owns the court
+ (interlude/horde/bossFight). All present and future callers inherit this.
+- `splatPoint()` is a no-op during the duel — `resolveInterlude` is the duel's only exit. (A machine
+ or leftover killed mid-duel used to bank score and could re-enter `proceedAfterClear` mid-duel.)
+- **Invariant net** in `watchdog()`: if horde+interlude ever coexist anyway, or the phase leaves
+ `rally` during a horde, it repairs on the spot and logs `INVARIANT` into `G.missLog`.
+
+**The game now has tests: `node test.js`** — headless Node, real game modules, stubbed platform
+(DOM/WebAudio/storage). Seven cases pin the ownership contract, including a scripted repro of the
+field freeze and the injected-corruption repair. Run them before touching `gameplay.js` beats.
+
## Session log: the testgroup round — popups, ghost hand, barrel economy (2026-08-08)
First round of feedback from the small test group (via Oliver), seven items, all landed.
diff --git a/gameplay.js b/gameplay.js
index 781a0d6..4dd1517 100644
--- a/gameplay.js
+++ b/gameplay.js
@@ -470,6 +470,10 @@ function pointTo(side, reason) {
const blocksClear = z => !'rmo'.includes(z.variant);
export function splatPoint(side, quiet = false) {
+ // The duel is one clean tennis point and resolveInterlude is its ONLY exit. A machine or leftover
+ // killed mid-duel used to score here, and a game banked mid-duel re-entered proceedAfterClear —
+ // which can start a boss, a card, or a horde while the duel still owns the court.
+ if (G.interlude) return;
if (side === 'P' && ++G.killCount < TUNE.killsPerPoint) return; // K kills bank one point; the rest just splatter
if (side === 'P') G.killCount = 0;
const gameOver = advancePoint(side);
@@ -708,7 +712,9 @@ function tickTestMode(dt) {
// 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() {
- if (G.interlude) return;
+ // 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
@@ -786,6 +792,19 @@ export function endBossFight() {
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
@@ -867,7 +886,7 @@ 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) return;
+ 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
@@ -1499,7 +1518,10 @@ export function update(dt) {
if (G.meleeT > 0) G.meleeT -= dt;
if (G.rackGlow && (G.rackGlow.t -= dt) <= 0) G.rackGlow = null; // the fresh-pickup pulse fades out
watchdog(dt);
- if (G.clearing && !zombies.some(blocksClear)) proceedAfterClear(); // the won-game wave is mopped up — advance
+ // !G.horde: a horde mows the court down to an EMPTY FRAME between spawns, and a clear latched
+ // before the wave began (a game won during the set card) then advanced the beat mid-horde —
+ // which is how a sudden-death duel once started INSIDE a horde. The clear waits for endHorde.
+ if (G.clearing && !G.horde && !zombies.some(blocksClear)) proceedAfterClear(); // the won-game wave is mopped up — advance
if (G.mode === 'transition') transition(dt);
// --- interlude trigger: the court is clear, an opponent holds the far court, you're about
diff --git a/index.html b/index.html
index 309d80c..037da56 100644
--- a/index.html
+++ b/index.html
@@ -398,6 +398,6 @@
-
+