# PROJECT — Splatter Tennis **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. **THE SUITE IS DETERMINISTIC ON PURPOSE (`Math.random` is seeded at the top of `test.js`).** It is a Dockerfile build stage, so a red run blocks the deploy — which makes a test that fails one run in five *worse than no gate*, because it trains you to re-run it. Two drill tests did exactly that and the pipeline caught what sixty local runs had not: one asserted on a random batch composition, the other on a SNAPSHOT of a population that legitimately empties (an eater crosses the whole court in ~24 s and despawns, so "no eaters on court at 40 s" is normal, not a failure). **Fuzz with `SEED=1234 node test.js` whenever you touch anything probabilistic** — and beware the cost of one fixed seed: a guarded test can silently take its early-out every run and quietly stop testing anything, so if you add a `return` for an awkward deal, check the body still runs. **`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-21 (THE CROWD: rolled again, in far more detail, once for all three courts; every frame is the seated sheet plus that pose's raised arms and nothing else, the sheet is a real three-frame animation sheet, and the stand animates ONE SPECTATOR AT A TIME off `src/crowd.js`. The wave, the idle fidget, paparazzi that scale with the moment, and an aww that still claps. See "THE CROWD, ROLLED AGAIN". Before that 2026-08-20 (THE SHELL: a Capacitor iOS project, haptics on the existing sfx call sites, portrait enforced in Info.plist, and swipe-left-to-go-back in the menus — the web build unchanged and still primary. See "THE SHELL"). Before that, same day: the friend across the net gets his OWN sheets — the player's kit, drawn from the front, so his pick dyes the same garment yours does; the setup screen shows that front too. See "THE FRIEND ACROSS THE NET WEARS YOUR KIT". Before that 2026-08-18: the store model in LATER revised: free download + €2.99 unlock-everything IAP with the free-tier gates, superseding paid-upfront; before that 2026-08-15, two sessions: the finetuning pass — gore opens in the gore, the menu theme comes back, one world board per mode, and the barrel economy cut — over the batch below: six new opponent families and an 18-rung ladder, the tournament DRAW screen, three netplay fixes off Oliver's cellular report, the night court, and camera flashes in the stand). **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 below them, newest first. **Awaiting his next phone playtest.** ## What this is SNES-style pseudo-3D tennis (Super Tennis reference) that transitions into a zombie splatter game while keeping every tennis aesthetic (umpire voice, 15/30/40 scoring, ball boys, applause). Zero-dependency vanilla JS, native ES modules, no build step. Mobile portrait one-handed play is the primary target. - **Run locally**: `preview_start` name `splatter-tennis` (`server.py` on :8641, `.claude/launch.json`). It is a real server now, not `http.server` — **editing `server.py` needs a preview RESTART**, and a stale Python process looks exactly like a broken feature (it cost a debugging round). - **Deploy**: push to `git@gitea.wuwi.one:wuwi/splatter-tennis.git` (main) → Coolify webhook builds the Dockerfile. Oliver tests on his phone at splatter-tennis.wuwi.one ~30s after a push. **Bump `main.js?v=N` in index.html every time** or phones keep the old build. The Dockerfile must also COPY any new top-level file (it currently ships `index.html`, `manifest.webmanifest`, `server.py`, `src/`, `assets/` — `test.js` only feeds the test stage and is deliberately not in the served image). - **The image is `python:3.12-alpine` running `server.py`, no longer `nginx:alpine`.** The world board needs one process that can WRITE, and nginx is not one; everything the nginx conf did here (no-cache so a deploy lands on a plain reload, and `application/manifest+json`, without which Chrome refuses to install the homescreen app) is a few lines in `server.py`. Standard library only — no pip, no requirements file. - **`/data` MUST be a persistent volume** (Coolify → the resource → Storages → add a volume mount at `/data`). Scores live in `/data/scores.json`; with no volume that file sits in the container's writable layer and **every deploy silently resets the leaderboard to empty**, which looks exactly like the feature working until the first time you push. Verified both ways with a local rebuild. Staging and prod are separate resources, so they get separate volumes and separate boards — which is what you want, or staging tests would land on the real ladder. - **Staging**: the `staging` branch deploys to its own subdomain (own Coolify resource, same repo, branch `staging`, same Dockerfile). Test there first, then merge to `main` for the testteam. The branch carries NO diff against main, so it fast-forwards and merges freely — keep it that way. A subdomain, not a subfolder, so staging gets its own origin and can't stomp the prod localStorage. Two Gitea webhooks, branch-filtered `main` and `staging`, both hitting the same Coolify URL (it matches the resource by repo AND branch). A filtered hook's "Test Delivery" always looks dead — Gitea fakes a push on the default branch and its own filter drops it. Push if you want to test. - **Oliver edits files himself** (tuning values, sometimes sprites). ALWAYS Read current file state before editing; never assume your last version is what is on disk. ## Module map (game code in `src/`; `index.html`, `server.py` and `test.js` at the 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 · `beats.js` **the beat machinery: every mode transition (duel/horde/boss/changeover/drill/`proceedAfterClear`) — ONE MODE OWNS THE COURT, and each `start*` enforces that itself; a new mode starts there and refuses there** · `drill.js` the coaching drill's own clock, bodies and rules (its transitions live in beats.js like every other mode's) · `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**. In splatter you serve at zombies; `ball.pow` ≥ `TUNE.zSplatQ` splatters, below that it staggers. Tennis scoring keeps running. `TUNE.killsPerPoint` kills = one point; games/sets accumulate; 6 games = a set = a **horde** (racket auto-fires, finger steers you absolutely). Odd games end in a **changeover** breather. A won game leaves the wave standing — you must **clear it by hand** (`G.clearing`) before play advances (`proceedAfterClear`). Every **10 games a CHAPTER BOSS** walks on (`G.bossFight`) — it clears the stage, blocks all spawns, and volleys at you. Every 4 games a **rogue ball machine** is wheeled out. Both attack the second damage channel: `shots[]` chip `G.hp`, which only a won duel heals. Contact is still instant death. Weapons are **only ever won**, in the **sudden-death duel** (`G.interlude`) against the racket zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixture he used to be). Getting caught = `G.mode='eaten'` = permadeath, and the run summary is the whole game. ## Architecture & invariants — know these before touching anything - **`G.modal` is the only thing that stops the world.** `update()` early-returns on it (after flash/card decay), so no zombie steps, no ball flight, no spawn timers — only `idleT` ticks for the blink. `showModal`/`dismissModal` in gameplay.js; `drawModal` is the LAST thing render draws. Dismissal must **swallow the gesture**: input.js `touchstart` returns on it, keydown returns on it, and the `pointerdown` dismisser is registered **only on `pointer: fine`** — on touch, pointerdown fires *before* touchstart, so a shared handler cleared the modal and let the touchstart underneath serve. `G.seen.` gates first-time-only modals. **POLICY (2026-08-08, testgroup round): a modal may only fire on a guaranteed-calm court** (duel intro, boss walk-on, post-duel healing, test mode, the horde's first-ever arrival). Anything that can land on a live court announces as a CARD or a flash — the freeze itself is safe (measured: a body moves 0.000 m behind a modal), but the stolen tap and the broken rhythm cost runs. **`m.in` (0..1 over `TUNE.modalIn`) is the arcade entrance** and the ONE thing that animates behind the freeze — it is ticked inside that same early-return, so it advances while nothing else does. Icon and title fly in from the right (with a two-ghost speed streak), body lines cascade from the left, all on `easeOutBack`; every x is `Math.round`ed, because a fractional x on a nearest-neighbour scaled image re-snaps its pixel columns (the boiling that once read as HUD flicker). A modal with no `in` field draws fully landed, so older/test call sites still work. **`m.hold` is an optional self-dismiss clock** (seconds, ticked only once `m.in` has landed): the modal announces, holds the frozen court, and lets go by itself. The chapter-boss walk-on is the case it exists for — an announcement whose job is to PREPARE the player, where demanding a tap to start a fight he did not ask for would be the control steal the popup policy exists to prevent. A tap still skips it. Every other modal has no `hold` and waits for the player, as before. While `m.in < 1`, `dismissModal` **returns true without dismissing** — it swallows the gesture but won't let a panic tap that was already mid-air skip a popup nobody has read. Returning false there would drop that tap through into a serve instead. - **The difficulty director lives in `updateZombies`'s spawn block + `spawnZombie` + `pickVariant`, and every dial is keyed on `G.runGames`.** It is the thing that broke first: as shipped, ALL of it bottomed out by about game 12 (body cap pinned at 14, spawn gap floored at 1.0 s, variant mix frozen at exactly 0.42 advanced, shamble speed maxed after 105 *seconds* of splatter, boss stuck at 3 hp, every horde identical). Oliver played 50+ games and reported the plateau; games 12-50 were arithmetically the same game. All six now keep climbing — see the TUNE block marked THE DIFFICULTY DIRECTOR. Calibration rule: the curve must **track the old values through ~game 12** and only then extend, or you have silently nerfed the part of the game that was already fine. The raw dials do run out around game 25 by design; bosses and ranged attacks (steps 4-5) are what carry escalation past that, not more bodies. - **`woundStage(zb, n)` in config.js is the ONE rule for how damage READS.** 0 = unhurt, 1..n = the skins, spread evenly over `hpMax`. There used to be three rules: the brute's hardcoded stage array indexed by absolute hp, a single skin behind `'abd'.includes(...)`, and the boss's fraction-of-hpMax. The boss's rule reproduces the other two EXACTLY (5 hp over 4 skins is one skin per hit; 2 hp over 1 skin is the shambler) and keeps working when hp is not one-per-skin — which the ball boy (3 hp, 2 skins), bouncer (6/3) and roller man (9/4) all need. So a new multi-hit body is now one row in `config.ZWOUND` (healthiest first, `…b` stride twin looked up automatically) and NO code. `bleed()`'s anchors are read through the same call and are deliberately the same length as that variant's ZWOUND row, so the blood and the drawing it comes out of cannot drift apart — they used to be written by hand against different constants. **'r' and 'B' keep their own skins** (the boss's on its design row) but go through the same `woundStage`. - **A chapter boss's MOVEMENT is data on its `BOSS_ART` row** (`move`, plus optional `hpMul`, `speedMul`, `volley`, `gap`, `spread`, `cycle`, `wind`, `shot`), and `bossAI` in zombies.js is the only place it moves. Every field is optional, so a row carrying nothing but art behaves exactly as a boss did before there were any. Three things that are load-bearing and were each a bug first: **(a)** the generic forward step at the top of the body loop SKIPS `'B'` — bossAI owns both axes, and leaving it in advanced the boss twice per frame; **(b)** the stride steps off `zb.dist` (ground covered on both axes), not `zb.z`, or a boss strafing sideways slides with its feet frozen; **(c)** a boss holds a z BAND (`bossZMin..bossZMax`) except while charging — uncapped, a fight ended by the boss simply arriving after about a minute, and every extra hp made that MORE likely, not less. **No style may rely on the player moving to create its own motion**: in splatter he is rooted in `pretoss` for nearly all of a boss fight, so "close on the player's x" arrives, stops, and is static for the rest of the fight. Every style moves under its own power and only BIASES toward him. - **`wound(zb, amount, fx)` in zombies.js is the ONE place damage is applied to a multi-hit body.** Fractional amounts are legal (a pellet chips `TUNE.dmgPellet` = 0.34 of a point); they bank on `zb.chip` and only WHOLE points come off `zb.hp`, because the wound skins and `bleed()` anchors switch on hp thresholds and a fractional hp would never match one. It returns true when the body should die and the CALLER kills it — `splatter()` when the kill should get the multi-kill fanfare, `killZombie()` for a quiet one — after which the caller must stop touching `zb`, because `killZombie` splices the array. A boss with 20 hp therefore needs no new damage code at all. - **Boss damage state is read as a FRACTION of `zb.hpMax`, never against literal hp numbers.** `spawnRacketZombie` scales hp with `TUNE.racketHpPerGame` and stores `hpMax`. Two places switch on it and they MUST stay in step: `drawZombie`'s skins (`<= 0.34` headless `z_cw2`, `<= 0.67` torn-arm `z_cw1`) and `bleed()`'s wound anchors. The old hardcoded `hp === 2` left a scaled boss looking pristine until the killing blow. - **The racket boss has NO health bar, deliberately.** It was the only floating UI on a body anywhere, it was on screen for nearly the whole run (a boss respawns every game you win), and players did not know what it meant. The wound skins already encode the same information in the language every other multi-hit enemy uses. A one-per-run modal (`G.seen.racket`, fired from `spawnRacketZombie`) explains what he *is* instead. Do not re-add a bar; give a new boss more wound stages. - **There are TWO damage channels and they must never be merged.** Contact with a body is still instant permadeath (`killPlayer`, `G.mode='eaten'`); ranged attacks chip `G.hp` (`hurtPlayer`). Oliver settled this explicitly — two threats that demand two different responses is the depth, and making contact chip instead of kill would lose the dread the whole game runs on. `killPlayer()` is the single death path for BOTH causes (extracted for exactly that reason: the run summary, the best-run localStorage write and the death animation exist once). `TUNE.hurtGrace` is what stops a three-shot volley deleting you in one frame. - **The player's health is drawn ON HIM and nowhere else.** No bar, no meter, no HUD number — same rule that removed the boss's bar. `drawPlayerWounds` paints procedural spatter at FIXED anchors (fixed so it cannot crawl frame to frame), `bleedPlayer()` adds the live trickle, and `G.hurtFlash` washes the screen edges inward on impact. Blood is never painted into the player sprite: that is a settled feel decision, which is why this is an overlay and not a set of bloodied-kit PNGs. - **`shots[]` in zombies.js is the HOSTILE ball array** — the ball machine's shots and the chapter boss's volleys. Deliberately separate from `ball` (there is only ever one of those and it is yours) and from `extras` (also yours, and they kill zombies). Three outcomes, in this order: a swing inside the volley window RETURNS it — as an `extras` clone at `TUNE.parryPow`, so "a parry is a real weapon" costs nothing to build; `meleeSwipe` merely bats it away; reaching you unanswered costs hp. **The swish path is not optional politeness** — in `pretoss` the player is rooted and cannot swing at all, so without it a machine winding up while you lined up a serve was an unanswerable hit. - **`isBoss(zb)`** (`'r'` or `'B'`) marks bodies that take `wound()` damage from everything instead of being deleted by it. The brute `'g'` is deliberately NOT one: melee/bomb/fire one-shotting a brute is a settled "power exception". Every weapon path checks it — miss one and that weapon silently no-ops or one-shots a 34 hp boss. This has already been the bug twice. - **The chapter boss scores NOTHING when it dies.** Its reward is the chapter card plus the parked game beat resuming. Letting it bank a kill meant `splatPoint`'s game-over branch and `endBossFight` both decided "what happens next" in the same frame and clobbered each other. - **`blocksClear`** decides which bodies gate the hand-clear after a won game: NOT the racket zombie (it keeps its residence) and NOT the ball machine (furniture — a standing threat you deal with when you choose). Making the machine gate progression turned "decide what to prioritise" into a chore every four games. - **The parry is bound to the TAP, in every phase, and that is load-bearing.** `keyPressed` checks `shots` for anything within `TUNE.parryArm` FIRST and turns the tap into a swing if so. Before that, the parry sat behind the `phase === 'rally'` branch at the bottom of `keyPressed` — and splatter is in `pretoss` nearly all the time, so a tap aimed at an incoming shot tossed the serve ball instead and the return was unreachable in practice. Oliver could not land one in a whole session. `TUNE.parryReach`/`parryWindow` are deliberately much more generous than a normal volley (2.4 m / 0.34 s), because this is a phone, one thumb, against something aimed at you. The fail cases still exist and are what keep it a timing skill: tap before the shot is inside `parryArm`, tap after it has passed, or don't tap. **`parryWindow` must stay <= `swingWindup + swingWindow * VOLLEY.window`** (0.348 s) or update()'s swing clock expires before the window closes. `meleeSwipe` deliberately does NOT touch `shots`: it runs first in `keyPressed`, so it used to eat the very shot the parry was about to return. - **`zb.hitT` (`TUNE.bossHitCool`) stops a LINGERING ball multi-wounding a boss.** A flame ball has full pierce — it is not consumed on contact, so it can cut a lane — but a boss is not removed when hit, so the same ball sat inside its radius and re-wounded it *every frame*. One flame ball killed a 16 hp chapter boss. Flame now also spends itself (`lastHit = 'X'`) on a boss specifically. Any new weapon that is not consumed on contact must set `hitT` too. - **No blood is painted onto the player.** Oliver: *"the player blood looks odd, appears as a painted layer on top"*. His health reads as `bleedPlayer()`'s live drip plus the screen-edge wash on impact, and nothing else. This is the same settled rule that governs the zombies — blood is never painted into a sprite — and a `drawPlayerWounds` overlay broke it. - **Boss designs are DATA: `BOSS_ART` in config.js, one row per chapter, cycling.** Each row is `{ name, walk: [2], wounds: [...], throws: [...] }`; `spawnChapterBoss` stamps the row onto the body as `zb.art` and `drawZombie` reads only that. Damage picks a wound stage as a fraction of `hpMax` spread over however many stages the design HAS, and `throws` is index-clamped, so a design with fewer throw frames than wound stages just reuses its last one. Adding a boss is a sheet plus a row — never a change to drawZombie. Missing/unloaded sprites degrade to design 1 rather than vanishing, which is convenient and also means **a wiring bug looks exactly like working code** (see pipeline note 7). - **Sprites are 2x (`SPRITE_H` 224, `_slice.py` `IDLE_TARGET_H` 224) and EVERY scale-critical sprite must share that scale.** `RES` is 3, so the player at the baseline rasterises into ~170 DEVICE pixels — a 112px source was being UPSCALED 1.5x, which is why near bodies looked soft. Sprites that go through `drawChar`, `drawBoy`, `drawMachine` or the wound-stage `h *= img.height / SPRITE_H` rule are all measured against `SPRITE_H`, so a sprite left at the old scale renders at HALF height. That is why the remaster 2x-upscaled the ~29 frames it could not re-slice rather than leaving them. Sprites drawn through plain `drawStatic(name, x, z, hMeters)` only care about ASPECT, so their resolution is free. **Heights above 224 are deliberate, not bugs**: a smash or toss pose SHOULD draw taller than idle (`p_smash` is 322), and that ratio is exactly what `SPRITE_H` exists to express. - **The umpire's asides are SITUATIONAL, never a timer pool.** `aside(name)` in gameplay.js is the only entry point and enforces one shared cooldown (`TUNE.asideEvery`), so several triggers in the same moment still yield one remark. The first version drew from a bag on a timer and Oliver's verdict was "too much and not situation aware.. they seem random". Each line is now tied to the thing that justifies it — a new variant appearing, the court filling up, one crossing your baseline, a game lost, a tin delivered, a horde, a changeover, a machine, a boss, the trainer, taking a hit. - **The portrait viewport ADAPTS to the device, and `TUNE.horizon` must ride with it.** `fitViewport` picks `h = 270 * clamp(innerHeight/innerWidth, 1.6, 2.3)` — a fixed 270x480 is 9:16 while a modern iPhone is ~0.46, so width bound the fit and ~150 CSS px of screen went unused. `proj()` places the court in ABSOLUTE logical px off `horizon`, and the rack/meter hang off `H - ...`, so extra height lands entirely in the apron between them: leaving `horizon` fixed just grew a dead green band. It is computed as `45 + (h - 480) * 0.72` for that reason. `RES` is 4 (was 3) so an iPhone's ~1125 device px across is nearly 1:1 with our 1080 — **`RES` is the first knob to turn down if the phone ever stutters**, it is the whole fill-rate budget. Note `#hint` (the desktop key legend) is already `display:none` on `pointer: coarse`; it only overlaps the rack in the desktop test pane, which is not a real bug. - **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. - **`render()` clears the whole frame to `pal.out` first.** There was NO clear for a long time: the stands cover the top and the out-area quad covers the middle, so it looked complete — but the quad only reached z=30 and BELOW where that projects the canvas was simply transparent. On a phone that read as a duller band across the bottom of the screen. The quad now runs to z=60 as well, so the perspective floor reaches the bottom on any aspect ratio. If you ever add a new orientation or stretch the canvas further, check the bottom rows are opaque — sample `getImageData` alpha, the colour alone will not tell you, because the page behind is deliberately the same green. **Do NOT try to cover the bottom by extending the out-area quad instead.** `proj()` divides by `(camZ - z)`, so as z approaches `TUNE.camZ` (48) the scale explodes and past it goes NEGATIVE — a quad taken to z=60 folded its far edge back above the screen as an inverted triangle. The clear is what covers the screen; the quad only has to cover the court. - **The page gradient goes on `document.documentElement`, and `html` needs a real height.** Two separate CSS traps, both hit: the ROOT element's background is what paints the viewport, so setting it on `body` while `html` also has a background painted nothing; and a GRADIENT is laid out in the root's own box, which collapses to zero when the canvas is `position: fixed`, so `html { height: 100dvh }` is load-bearing rather than cosmetic. - **The page background behind the canvas tracks the live court apron** (`pageBackground` in render.js). It is visible in exactly two places: the strip above the canvas under the status bar, and the slivers a phone's ROUNDED DISPLAY CORNERS clip out of the canvas — a flat dark body drew a visible dark notch where the bottom corners curve in. It is a two-stop gradient: `#10141c` down to the top inset, then `pal.out` for the rest. **`pal.out` must be read live**, because it lerps between the tennis and splatter palettes and any fixed hex would only match in one mode. Written in resolved px rather than `env()` so the declaration can never be invalid and drop the background, and only assigned when the string actually changes. - **The safe area is handled ASYMMETRICALLY, on purpose.** TOP: index.html insets the canvas element below the status bar, because that is opaque UI the scoreboard must clear - `SAFE.top` stays 0, and adding a HUD offset there as well subtracts the notch twice. BOTTOM: the canvas deliberately runs to the PHYSICAL bottom edge so the court art fills the screen (the home indicator is a slim translucent bar, not opaque UI); only the interactive RACK and the draw meter lift clear of it, via `SAFE.bottom`, which `measureSafe` reads from the hidden `#safe` probe (env() is CSS-only). Reserving the bottom inset on the element instead just left a dead band. - **The canvas is STRETCHED by CSS (100vw x 100dvh minus insets) and `fitViewport` derives the logical grid from the box it was actually given** — `cv.clientWidth/clientHeight`, never `innerWidth/innerHeight`. 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 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 `env(safe-area-inset-*)` (env() is only readable from CSS), `measureSafe()` converts it to LOGICAL canvas px using the same scale `fitViewport` just applied, and `SAFE.top`/`SAFE.bottom` shift the scoreboard down and lift the rack + `METER_Y` up. Anything new that anchors to the very top or very bottom of the screen must offset by these too — and the rack's hit test shares `rackGeom`, so taps follow automatically. - **`UI` in config.js is the accent palette** (hot/warm/pick/cream/dim/bad/ball). Every HUD colour goes through it. Gore reds are deliberately NOT in it — the splatter stays loud. - **`F(size, bold)` in render.js builds every canvas font string.** Bare `monospace` resolves to Courier, a slab SERIF — that was the "generic serif" bug, not taste. Never hand-write `ctx.font`. 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()` (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`, `G.modal`. `G.mgT` in particular must keep counting down in EVERY phase (it used to tick only during `rally`, so a duel calling `setupPoint()` stranded it above zero forever = permanent lock). - **Animation frames step off DISTANCE COVERED, never elapsed time** — `boy.dist` for ball boys, `zb.z / TUNE.bruteStride` for brutes. Time-driven cycles moonwalk the moment speed and frame rate disagree. Sprite heights follow the PNG (`img.height / SPRITE_H`) because `_slice.py` common-scales a sheet; a wound stage's height must ALWAYS come from its A frame or it pops. - **Fire chains.** `zb.burnT`/`burnD`; `ignite()` is read-only over `zombies[]` so it is safe inside a kill loop; `burnTick()` returns true when the fire killed the body and the caller MUST `continue`. The flame ball also ignites what it FLIES PAST (`TUNE.burnBallR`) — without that its full pierce killed its own lane outright and nothing was left to catch. Flames are drawn procedurally (`drawFlames`), no sprite sheet. - **Any loop over `zombies[]` whose body can kill** can cascade into a sweep and gut the array mid-iteration — keep the `if (!zb) continue` guards and iterate backwards. - **The ball boy is ONE slot.** `sendBoy` never replaces a boy already on court (a kill streak used to teleport him back to the wing several times a second); new work is appended via `jobs`. `mopRound` walks him to a stain, holds him there for `TUNE.boyMopDwell` while the push cycle animates in place, then moves him on. Three sheets: `x_boymop` (stopped, working), `x_boycarry` (walking with the mop shouldered), `x_boyrun` (tennis-mode fetch). - **THE CROWD SHEET IS AN ANIMATION SHEET, and every frame of it is frame 0 plus arms.** One file per court, `FRAMES` frames stacked vertically: 0 seated (what the cached backdrop draws), 1 arms raised high, 2 hands together in front of the face. `assets/_crowdpass.py` builds 1 and 2 by copying, out of two further rolls, ONLY the pixels that were STAND in the seated roll and became PERSON — about 1.2% of the sheet each. Not a raiser's face, not his shirt, not his seat: those are frame 0's own pixels and cannot drift. **`src/crowd.js`** (generated) boxes each of the 134 spectators who can move, with a mask of which raised frames they appear in — 28 own both, and those 28 cycle between the two poses, which is a clap rather than an appearance. render.js animates **one spectator at a time** out of that table. It was NOT true before 2026-08-21: the clap sheet was a separate roll differing from the seated one in 13-16% of its pixels, everywhere, so the animation re-hashed the crowd rather than raising arms — Oliver, "the crowd wobbles with a client side animation right now.. that feels odd." **Never hand-edit a crowd frame, and never edit `src/crowd.js`.** Re-roll with `_crowdroll.py`, then re-run `_crowdpass.py`, which rebuilds every frame, all three court grades and the table together. Draws stay integer-aligned with **no vertical offset**: a fractional `dy` on a scaled image re-snaps its pixel rows every frame under nearest-neighbour — that boiling beside the HUD was the "scoreboard flicker". - **THE APPLAUSE HAS TO HAPPEN WHERE THE HUD IS NOT.** A raise is only detectable where there is empty stand above somebody's head — that is how the arms are told apart from the roll's drift — and the sheet only offers that above the top row of each seating block. One of those rows sits at the very top of the picture, which is exactly where the scoreboard is: measured, 31 of 134 raisers were being drawn under the board or the blood panel. Oliver: "in the upper parts of the crowd there seems to be a lot more action going on, but I can barely see it because the hud is covering it." Two rules now hold the line, and BOTH are needed — the second alone would just delete the applause: 1. the arms-up rolls are asked for standers **in the lower two thirds only** (`_crowdroll.py`); 2. `_crowdpass.py` drops any raiser whose box centre falls under the board or the blood panel (`HUD`, in portrait logical px — landscape has a shorter band, so the rule is conservative there rather than over-eager). Result: 92 of 101 raisers below the board's bottom edge, against 103 of 134 before. - **A raise must be ARMS OVER STAND, and `_crowdpass.py` tests all three words.** The rolls repaint clothing as well as posing people, so "was stand, is now person" also catches a cream shirt that came back a shade brighter (`PALE_MAX`) and a genuine arm drawn over something already bright, where raising him DELETES a white block (`COVER_MAX`). Both flash exactly as hard as a real error would. - **A raiser is ~8x6 logical px, so a pose that appears and holds is invisible.** What reads as applause is the CYCLE — arms high, hands together, up, down, at about 3.5 Hz — and how many people are doing it at once (`crowd.pop`). Oliver on the first per-person cut: "when clapping I barely see any movement at all now." - **The three courts are ONE crowd, graded.** `x_crowd`, `x_crowd_c` and `x_crowd_h` are the same roll under three per-channel histogram matches onto the courts' old palettes, applied to the whole stack at once so the frames cannot drift apart in colour. They were three separate rolls until 2026-08-21, and the cost of that was exactly what Oliver reported: "the whole thing somehow does not translate well to the other courts, there the difference is barely visible." - **The stand sits BACK, and the two dims do NOT stack.** `standDim` is `court.dim ?? TUNE.standBack` plus the splatter rot — a court that names its own darkness REPLACES the default instead of adding to it. Adding was the first cut and it made two courts unreadable: centre court's crowd sheet is a night-lit navy to begin with and the night court dims 0.5 on top of the hard-court sheet, so both went to mud (Oliver: "it is too dim to see anything on the centre court and the dark court"). Centre court therefore carries `dim: 0.06` of its own, and its sheet is also graded off a LIFTED target (gamma 0.72 in `_crowdpass.py`) because the old centre-court crowd it matches sat at a mean luminance of 39 against clay's 96 — dark enough to throw the new roll's detail away before the dim even ran. On the default itself: "all of the art could be colored a little more dim as it is perspective wise farther in the background and should not steal too much attention." `assets/_sheet_crowd1.png` is the pre-brightening original of the FIRST crowd, kept for history. ## Settled feel decisions — do not regress silently - **ANYTHING DRAWN OVER THE CACHED STAND MUST CARRY THE STAND'S OWN TREATMENTS.** There are two — `standDim` and `topFade` — and a raiser box drawn with one of them missing lights up as a bright rectangle every time that spectator stands. That is what the "white flashing in the upper right corner of the crowd" was (2026-08-21): the top fade was baked into the backdrop and the live raisers were not repainting it. Both now come from one function each, called from `paintBackdrop` and from `drawRaisers`. The gradient is built in USER space, so filling any sub-rect with it picks up the right alpha for free. Two copies of either number is the same bug waiting again. - **The frame is dark at BOTH ends.** The run-off apron already took the bottom of the screen down to near-black below the near baseline; `TUNE.standTop` does the same at the top, so the stand fades UP out of the dark instead of being cut off at y = 0. Oliver, 2026-08-21: "the bottom of the screen falls off into a black tone.. please make the top come out of a black tone." It also gives the scoreboard something to sit on. - **Every HUD element on top of the picture carries a drop shadow** (`render.hudShadow`, and the same numbers in CSS on the quit chip): the board, the game/sets panel, the BLOOD panel and each rack tile. They sit on a crowd, and half the overlay lifting off the backdrop while the other half does not is worse than neither. Canvas shadow offsets are NOT scaled by the transform — they are device pixels — so `hudShadow` multiplies by `RES`, or a 3 px shadow reads as three quarters of a logical pixel on a 4x phone. It is sticky state: turn it off the moment the element is drawn. - **Far bodies draw off a flattened scale curve; the camera holds still.** `TUNE.farK` 0.25 (render.js `farB`): the court projects true, bodies grow toward the far baseline (~20% at the back), anchored so the near player never changes. Oliver: *"new perspective is really good — helped a ton."* The ball-follow lean built alongside it (`farCamY`/`farCamZ`) was vetoed the same day — *"the camera movement is distracting"* — and sits at 0 like the aimCam pair: every camera-motion experiment on this game has now lost to a still camera. - **Two-stage serve**: touch = reticle, drag = aim, release = fire, drag back under `pullMin` = cancel. The pull's DIRECTION picks the lane, its LENGTH is power — deliberately decoupled so a far shambler never costs a longer drag than the screen has. - **The manual wave clear** after a won game (`G.clearing`). Oliver was emphatic. - **Thin, dynamic blood.** Open wounds gush live particles (`bleed()`); blood is never painted into a sprite. Gore piles/stains are the loud part; keep the trickle sparse. - **One gesture means one thing** on touch. No mode where a drag silently becomes something else. - **Volleys**: pre-bounce contact gets reach ×1.4 / window ×1.8 (`VOLLEY`). A RISING ball is hittable at any height; only a dropping ball below `CONTACT.minY` is past you. - **Aim magnet**: `flickTarget` picks the best-ALIGNED body, with `TUNE.aimTie` of slack in which a nearer one still wins, so a straight-ahead lane still eats the nearest body first. `aimTie: 1` restores the old "nearest along the lane always wins" for A/B. - `TUNE.fps` 60 — 30 was vetoed on feel. - **The player only walks during `rally`.** Never between points. Letting him walk during the 'over' beats and `pretoss` was shipped once and vetoed immediately: *"I basically can never walk around freely"* — so suddenly being able to, only between points, reads as the game breaking rather than as a fix. Frozen-with-danger-approaching is fixed by holding the DEAD instead (`held` in `updateZombies`), never by handing back movement. - **The player must always be ON SCREEN.** `P_BOUNDS.x` is 6.5 m but portrait only shows about ±4 m either side of the centre line at the baseline, so the walk bound alone let him leave the frame entirely. `xLimit()` in gameplay — the camera limit, floored at the sideline so every legal ball stays catchable. The walk and the sidestep share it. - **The auto-assist recovers in DEPTH ONLY in splatter — it must never undo a sidestep.** The recovery target used to be `HOME.P`, x included, so firing walked you back toward the centre line: measured, a step out to 1.94 m was dragged to 0.77 m over one ball's flight, i.e. most of a dodge you had spent a cooldown on, handed back for free. That is the "welded in place" complaint the sidestep exists to answer, arriving slowly instead of all at once. **Tennis keeps the full recovery** — recovering to centre is the real sport's footwork and there is no sidestep there to protect. (Oliver: *"the dodge swipe should not reset to the middle when firing"*.) - **The sidestep is how you move outside `rally`, and it is not a walk.** One committed step (`TUNE.dodgeDist`) on a cooldown, carrying `TUNE.dodgeIFrame` seconds that beat BOTH damage channels — the chip damage in `hurtPlayer` and the instant-death contact test. It exists because a boss fight is spent in `pretoss`, so the rule above left you welded to the spot for the whole fight and Oliver died at every chapter boss without ever being able to move. - **Which way you leave the deadzone IS the gesture.** Decided once, at the `DEAD` crossing, and never revisited for that touch: down-ish = the slingshot, sideways = the sidestep. This is how the new gesture stays inside "one gesture means one thing" — there is no timing window and no hidden mode, only the direction you actually moved. The finger-steer modes (horde, burst) are untouched: `stick.aimable` is false there, so the drag is still the absolute steer it always was. - **Two type registers, and the register IS the message's category.** Silkscreen for the HUD, the rack, the control hints and `RESUMING IN` — things you read at leisure. Bangers (`B()` in render) for `SET`, `CHAPTER`, `EPIC!`, the praise words and every free-floating flash — things shouted at you. EVERY floating string goes through `shadowText`, both faces, because none of them has a panel and all of them have to survive the crowd, sunlit clay and blood behind them. The scoreboard and the rack are the two exceptions: they sit on their own dark panels, and a 1 px shadow under 6-8 px type on a flat dark fill only smears it. - **Praise is a narrow window, not a ceiling.** `pullQuality` SATURATES: `pullFrac` clamps at `pullFull`, so every pull past 130 px is exactly quality 1.0 and the old `quality > 0.999` test praised nearly every serve — *"it displays perfect a lot even when nothing special happened"*. `hitServe` takes `perfect` as its own argument now: the toss ritual still earns it on the 45 ms `perfectMs` window, the slingshot earns it by STOPPING in `pullFull..pullFull*perfectBand`. The draw clicks brighter when you enter that band and goes quiet when you overshoot — without that audible target it would be invisible skill. The word itself rotates through `PRAISE` (cycled, not random: the same word twice running reads as a stuck HUD element, not as praise) and sits behind `TUNE.praiseEvery`, the same shared-cooldown model the umpire's asides use — you LEARN to hit the band, and then a per-serve word is noise again whatever it says. The click is the feedback for a good pull; the word is garnish. - **The free-floating slot is 18px, not card-sized.** Half of what lands in it (the praise words, `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 SEASON, PHASES 1-4 — the front door, the record, the rooms, the season beat (2026-08-31, STAGING ONLY) All four phases of the plan below, plus the overlay consolidation Oliver asked for in the same breath ("die modals haben manchmal 2 boxen manchmal eine manchmal keine... der tap wird zu einem echten button im footer der box... zudem noch das run-ende, hier bitte auch mehr struktur"). **THE MODAL IS ONE BOX** (`render.drawModal`). Three drawers — the plain announcement, the rules card, the walk-on — became one renderer with slots: a MASTHEAD (art beside the title, with the body's strap under it), a stack of ROWS separated by hairlines (statement over consequence, the split figure of his level against yours, the blood pool as pips), and a FOOTER BUTTON. The payload is untouched: `title/body/icon/rules/foe` are the same fields beats.js has always sent, so the suite and every call site are unaffected — this is a layout. Two consequences worth knowing: - The entrance is now ONE object flying in from the right. The old per-element stagger would tear a box apart, and a box that assembles itself out of flying pieces is not a box. - The button says what HAPPENS (`PLAY ON` / `GOT IT` / `WALK ON`, `m.btn` overrides) instead of what gesture to make, and it appears only once the box has landed. **A tap anywhere still dismisses** — the button is the affordance, not a hit target. A modal you could only leave through one 26px strip would be a worse wall than the one we had. **THE RUN-END WEARS THE SAME BOX, AND HANDS OVER.** Headline and the run's line, then the xp it paid, then the mark, then THE FIXTURE with its buttons — `ENTER THE DRAW` beside `PLAY AGAIN` (or `THE CLUBHOUSE` when the run just played was the cup). `render.endHit(nx, ny)` gives the rects back to `main.maybeRestart`, which is where the tap already lived; a tap anywhere else is the old behaviour exactly, so nothing that worked before stopped working. Netplay gets ONE door (the clubhouse): dealing a solo cup into a session whose socket is still tearing down is not a handover, it is a bug waiting for a phone verdict. **THE CUP'S OWN EXIT IS A CEREMONY.** `OUT IN THE QUARTER-FINAL.` over `YOUR BEST STANDS: THE SEMI-FINAL` (or `THE BOARD NOTES IT` when the attempt IS the new record). The board's felled count is suppressed there — two bests answering different questions in one box was the wall of text this pass exists to remove. **THE RECORD** (`profile.cups` / `profile.out`, `recordCup`, `cupBest`). Two small fields: rounds ever reached per league, and the rung of the body that ended the last attempt. Written by the two places a cup run can end (`zombies.killPlayer`, `beats.cupWon`), mirrored server-side beside the xp (`server.clean_profile` re-derives them like every other field), merged FORWARD on pull. Shown on the plaque (which stopped repeating "win the cup to advance" — the fixture button below says that), in the league panel, and in the summary. Training reps write nothing: a rep is not an attempt. **THE FRONT DOOR IS THE FIXTURE.** `PLAY MATCH` became `LEAGUE 3 · THE CUP / 4 ROUNDS · ROUND ONE: THE STRAWBERRY SELLER` and enters the draw directly — the cup has no court to pick and no opponent to choose, so the setup screen there was a confirmation page with nothing on it to confirm. The poster wall moved one tap down behind `OTHER GAMES`. `config.cupFixture` is the single descriptor all three screens read, so the splash, the summary and the promotion cannot drift apart on its wording. **THE ROOMS.** The wall deals three named groups now — THE SEASON (the cup, alone), THE TRAINING (the climb, the training court), OUT OF COMPETITION (gore, classic, a friend) — and the cup left the ZOMBIE TENNIS pocket for the top. That also emptied the second step of everything except CLASSIC's difficulty chips. `MENU` rows with a `group` key are headings, not posters. **THE REVANCHE.** The training list puts the body that knocked you out of the cup FIRST, in a red tile that says `PUT YOU OUT OF THE CUP`. One remembered rung turns a shelf of eighteen equal tiles into the obvious answer to the last defeat — the cheapest coupling of Beiwerk to canon we have. **THE SEASON BEAT** rides inside the promotion (no new screen): `NEXT: LEAGUE 4 · THE CUP` over its rounds and its first body — or, for the champion, the title he now has to hold. Tests: two added (127 total) — the fixture descriptor against every league (including the champion's defence), and the record's forward-only write with the eliminator it names. `freshSplatter` resets `cups`/`out` for the same reason it resets the league: a cup ending writes them for real. **THE WALL'S HEADINGS BECAME SECTIONS** (same day, Oliver: "sind sehr klein, vielleicht koennen wir daraus richtige sektionen machen"). Each group is a `
` with its posters INSIDE it: the announcement face one size under the screen title, a rule closing the header, an inset band down the left, and the canonical room wearing the primary's colour. A heading floating over a stack is a label; the wall needed rooms. Open for the phone: whether the fixture button should ever go to the setup screen (it does not now), and whether the promotion's WALK ON should enter the draw instead of the clubhouse. ## THE SEASON — the tournament is the story (2026-08-31, AGREED in discussion; PHASES 1-4 BUILT ON STAGING — see the log above) Oliver's diagnosis, and it is right: "alles ist irgendwie beliebig... die Menüführung und die Start- und Endscreens enforcen keine Story-Line". The cause is not a missing story — THE LEAGUES already are one. It is that the story runs UNDERNEATH while a mode picker sits in front: five modes as peers on a poster wall, the goal a caption on the plaque, and every ending dropping you back on the splash with nothing handed over. The one screen that already works is the PROMOTION, because it says *what you did, what is new, where you go next* — that is the pattern the rest is missing. ### Decisions, settled with Oliver 2026-08-31 1. **THE TOURNAMENT IS THE CANONICAL PATH.** It is the only thing that moves the career. Every other mode is Beiwerk by design and stays that way — no second promotion route, ever. 2. **A CUP ATTEMPT IS ONE EVENING.** No resume across sessions, and **no seeding**: reaching the semi-final once does NOT let a later attempt start there. Elimination sends you back to round one. That price is what makes the cup a test rather than a queue — explicitly weighed against the friendlier alternative and rejected. 3. **Classic tennis stays Beiwerk** with no narrative role. Gore and netplay likewise: "ausser Konkurrenz" is their ROLE, not a demotion. 4. **Guidance is structural, never a lock.** The leagues' rule holds: nothing that is playable today gets sealed behind progress to manufacture a path. The player is POINTED, not fenced — two taps still reach anything. 5. **The story is carried by structure and screens, not by more prose.** The copy audit already proved the failure mode: rules buried in modal text do not land. No narrator dumps. ### The spine — five pieces 1. **THE FRONT DOOR IS THE FIXTURE.** The splash's primary button stops saying PLAY MATCH and names the next cup fixture instead ("LIGA 3 · THE CUP — FOUR ROUNDS · ROUND ONE: THE GROUNDSKEEPER"), entering the draw directly. The poster wall moves one level down behind a secondary door. One tap to the canon; one extra tap to browse. 2. **THE CLUB REMEMBERS THE ATTEMPTS.** Two small profile fields per league: the best round reached, and who eliminated you last. Shown on the plaque and the league panel ("BEST: SEMI-FINAL"). A lost cup stops being a null event and becomes a line on the board — this is what turns repetition into an arc, and it is the cheapest piece here. 3. **BEIWERK IS DESCRIBED IN CUP TERMS.** The poster wall groups into DIE SAISON (the cup) / DAS TRAINING (the climb, the training court, the drill) / AUSSER KONKURRENZ (gore, classic, netplay), so the menu itself answers "what does this do for me?". The strongest coupling: **after an elimination the training court preselects the body that knocked you out** — one remembered rung and one line of copy turn a generic practice mode into the answer to the last defeat. 4. **EVERY ENDING POINTS BACK.** Non-cup endings close on the fixture line and its button. The cup elimination gets its own small ceremony (the board notes how far you came) instead of the generic death summary. 5. **THE SEASON OPENS INSIDE THE PROMOTION.** No new screen: the promo screen gains a second beat naming what waits in the new league (rounds, who stands at the bottom of the band). Ceremony without another tap between the player and the court. ### The phases - **Phase 1 — the front door.** The fixture line + button on the splash, and the same handover on every ending. Presentation only; reads `cupRoundsFor` and the profile, adds no state. - **Phase 2 — the record.** The two profile fields, their display on plaque and league panel, and the elimination ceremony. - **Phase 3 — the rooms.** Poster-wall grouping and the training-court preselect. - **Phase 4 — the season beat** inside the promotion screen. Open for the build (not decisions): the exact fixture wording; whether the fixture button skips the cup setup screen entirely (it has no court choice anyway); how the record reads at league 5; and whether the elimination ceremony is its own screen or a variant of the run-end summary. ## Session log: the copy audit — every modal says what it means (2026-08-29, Oliver's ask) A sweep over every modal/card message for decoding-required copy, four finds fixed: - **The arcade's rules card said DEATH MATCH** — a leftover of the era when the solo mode WAS one; the picker has said ARCADE/THE CLIMB for a day. It wears THE CLIMB now ("THE WHOLE CLUB, BOTTOM UP / ONE GAME PER BODY — HOW FAR CAN YOU GET?"); netplay keeps DEATH MATCH (there it is literally the mode) and says "WOUND YOUR FRIEND". - **The tournament card's "UP YOUR LEAGUE'S DEAD HIERARCHY"** (the trigger for this audit) became "YOUR LEAGUE, ROUND BY ROUND / WIN IT ALL — MOVE UP A LEAGUE" — the payoff was never named. - **The ball-machine card never named the machine** ("SWING TO RETURN — OR SWISH IT AWAY", an instruction without a subject) — "A BALL MACHINE / SWING ITS SHOTS BACK — OR SWISH THEM AWAY". - **The drill's "FAST BEATS CLOSE · STREAKS PAY"** decrypted on the zoned card ("THE FASTEST BODY — NOT THE NEAREST" / "THE WRONG BODY BREAKS THE STREAK" — checked against drill.js's actual rule); the body keeps the old words for the suite. - **SUDDEN DEATH only sold the win** — it names both stakes now ("LOSE: THE DEAD TAKE A GAME / AND FIVE MORE CLIMB THE FENCE", the real resolveInterlude cost). Judged fine and left alone: the boss walk-on, horde, barrel, trainer, training-court and bench copy. ## Session log: the rules cards get zones too (2026-08-29, Oliver: "wichtige Infos, schwer zu erfassen") Same disease as the walk-on, same medicine: the three mode modals (THE TOURNAMENT, DEATH MATCH, THE TRAINING COURT) carried compound em-dash sentences edge to edge. `render.drawRulesModal` (driven by `m.rules: [{lead, sub, hot, pips}]`): each rule is its own boxed row — the statement in cream, the consequence beneath it DIM for information and the modal's hot colour only when it threatens (`hot`), and a rule about blood shows the pool as the HUD's own pips instead of a naked number. Entrance lanes, freeze and tap rules unchanged; title/body still carry the words. ## Session log: the walk-on gets its own face (2026-08-29, Oliver: "buried in a wall of text") The opponent intro carried six kinds of information as one modal line stack. It wears designated zones now (`render.drawFoeModal`, driven by `m.foe` from matchOpponent): the MASTHEAD (mugshot large on the left, the name in the announcement face, the slogan wrapped beneath — the shared wrapText, measured against the space beside the mug), THE MEASURE (a split .figs-language panel: ITS LEVEL boxed against YOURS, the number in the modal's hot red while the body outranks you and the pick green once it does not), the round/lesson strip, and the blinking tap. Same modal machinery throughout — entrance lanes (masthead from the right, the measure from the left), the frozen court, dismiss rules. m.title/m.body still carry the same words, so the suite and any legacy reader are untouched; the one icon-shaped assertion moved to m.foe.head. ## Session log: the look stops lying (2026-08-29, Oliver: "the man with a pink shirt") After a reload with the women's tour picked, the live-player preview showed THE MAN from behind in her kit colour — and the woman, when she did show, stood a head shorter. Two root causes: - **drawLook's fallback satisfied the retry poll.** `n_idle1_f || p_idle1_f || p_idle1` drew whichever loaded first and returned true — the caller's 200 ms poll stopped on the lie and the stand-in stuck. It still draws the stand-in (better than a blank) but returns done only when the WANTED frame drew. - **The 96px canvas width-bound the contain fit.** Both front sheets are 224 tall, but hers is 127 wide against his 114 (she reaches further — the TOURS note), so contain on 96 shrank her 10% more than him. The three look canvases are 128 wide now; both bodies are height-bound and stand equally tall. ## Session log: training pays a quarter, the bar wears its endpoints (2026-08-29, the batch's two discussions, both go) - **THE TRAINING COURT PAYS A QUARTER.** Zero pay made it feel pointless in a meta game (his words) — but full pay would make the no-permadeath, pick-your-body grind the optimal one. award() now quarters (rounds) training amounts instead of muting them; drill, bench and test mode stay muted. The footer says "NO RUN, NO DEATH — REPS PAY ¼ XP", the mode's one-time modal agrees, and training gained real practice value anyway since bodies train at their band's true tempo. The "NOTHING IS BANKED" half is still true: no run, no board, no ladder. - **THE BAR WEARS ITS ENDPOINTS**: "20 ▓▓░░ 21" on the plaque and the card (MAX at the cap) — he read the bare bar as total progress; the two numbers say "fill toward the next level" without a word of explanation. The card keeps its words and the CAREER bar as the long form. ## Session log: the licence, the peer line, the steward's vest (2026-08-29, Oliver's small-finds batch) Three of five items from his post-merge test built (training xp and the level/xp display are under discussion): - **THE WEAPON LICENCE hardened.** His catch: the duel's tin was league-gated but the BARRELS still paid every weapon. Root fix, not a barrel patch: the league is a LICENCE and the run's `from` gates are the PACING — both must hold, in `tinLadder()` AND `pickBarrelWeapon()`. The tins' old OR (league unlocks early) just moved his complaint one screen over, so it went: below league 3 there are no lasers anywhere, ever; rockets need league 4; the gore director's tuned income curve is untouched for licensed players. Tin test rewritten to licence semantics. - **THE PEER LINE**: every walk-on modal now carries "LEVEL 34 — YOURS: 20" (config.foeLevel — the ladder position spread over the profile's 50 levels, display only, nothing scales off it), so you can feel whether you are that body's equal yet. His ask, verbatim. - **THE STEWARD GOT HIS VEST BACK.** The tennis family was rolled off z_d1 (which wears the orange hi-vis) but the FAMS words said "dark uniform jacket" — and the words won. The words name the vest now ("on him in EVERY single pose"); family re-rolled through _famroll -> _zsheet, review-cut checked, installed (SPRV 149 -> 150). z_sthead was already cut from the gore frame and stayed byte-identical. NOTE: a full `_heads.py --install` rebuilds SIX OTHER head icons differently from what is installed (script drift, br/ch/dr/gk/ma/r) — those were reverted, not shipped as a side effect; whoever touches heads next should reconcile first. ## Session log: THE LEVEL IS YOU, IN THE LEAGUE — legs and window (2026-08-29, after the merge) Oliver's deep-test question ("wie wirkt sich mein level aus? werde ich schneller, genauer?") exposed two honest gaps: the level's buffs were defensive-only (the plan's serve/accuracy dials never landed) and linearly spread over 49 levels — imperceptible where he actually plays (level 20, league 4: "autorun schlicht zu langsam, das timing wird zu tight"). His directive: gore aside, the league must reward xp SUBTLY but really; level 1 stays the untouchable baseline. - **profile.bonus() is FRONT-LOADED now**: sqrt of the level fraction (the xp curve's own fast-early logic). Level 20 carries ~0.62 of each cap value; linear gave 0.39. - **THE LEAGUE PAIR**, league modes only (`G.match && !G.net` — classic keeps its picker, netplay stays a fair human match, gore untouched): `run` (cap +15%) multiplies INSIDE `assistK()`, so the mover's legs and intercept's reachability can never disagree (the rzChaseSpeed rule, player-side); `window` (cap +20%) widens trySwing's player contact window AND the update() swing-clock expiry that would otherwise close over it — and since quality is centre-normalised, the same millisecond error also scores HIGHER: quicker to the ball, cleaner when there. At level 20 that is ~+9% legs, ~+12% window. - Suite 125: level-1 identity, cap bounds, the front-load ratio, the league/netplay/classic gating — and freshSplatter zeroes xp now, because the level feeds real dials and accumulated test awards would silently buff every later test. ## THE LEAGUES LAND ON MAIN (2026-08-29, end of the build day) Oliver's phone verdict after the full staging day: "super — sehr gute session. bitte merge nach main." Everything from the plan through the four phases and the whole polish tail (the plaque, the climb, the deterministic cup, the boot gate) fast-forwards to main in one piece; staging and main are identical again, as the branch rule wants. Prod grows `profiles.json` beside `scores.json` on its /data volume on first write. The "STAGING ONLY" stamps on the logs below are history from here down. ## Session log: the boot gate learns about the profile (2026-08-29, same day) Oliver: on a fresh load the player/build requests are still in flight while the splash is already interactive. The existing `#splash.busy` gate (sprites + fonts) now ALSO waits for the profile mirror's pull — capped at 4 s, because a dead network must never park the player on the title (the sprite loader's own rule). While busy the PLAQUE is a skeleton: ghost crest, blank pills where the words go, empty bar, no chevron, `pointer-events: none` — and the splash keyboard shortcuts respect the gate too (they didn't). Half-true numbers neither show nor navigate; the pull's `refreshSplash` repaints the real values the moment the gate lifts. ## Session log: THE CUP CLIMBS — the shuffle retires too (2026-08-29, same day, STAGING ONLY) Oliver, straight after the draw screen went: "warum sollten wir noch mischen? wäre es nicht gut von leicht nach schwer zu spielen?" — right again. Without the screen the shuffle was invisible novelty, and a bad roll paired the strongest challenger with the one-game round one, INVERTING the curve the whole game is tuned around ("starts easy, gets harder" is literally test-pinned). `cupRoundsFor` is deterministic now: one band position per round, ascending, the final the top — and the setup's league panel is thereby an exact preview of the tournament order. `cupDraw` collapsed to a read; `cup.draw`/`cup.played` are gone (startMatch keeps the dead `draw` parameter for call-site arity only). The old draw test became the climb-order pin. ## Session log: THE DRAW SCREEN RETIRES (2026-08-29, same day, STAGING ONLY) Oliver: "the draw ist doch jetzt eigentlich obsolet, oder?" — yes. The screen was built for the 18-rung ladder (his own 2026-08-15 ask), where four bands rolling out of a deep hat was a ceremony; a league cup shuffles 2-3 faces the setup's league panel already shows face-up. Deleted: the #bracket stage (DOM, CSS, buildBracket, its timers and routing). What survives: the shuffle itself lives INSIDE the run (cupDraw, without replacement, the final settled — startMatch's `draw` parameter stays for the suite's validation test, renamed accordingly), each round still announces itself on the walk-on modal, and the cup's big setup button now says **ENTER THE DRAW** — all the ritual the moment needs. The rules modal's copy went league-neutral ("UP YOUR LEAGUE'S DEAD HIERARCHY — THE FINAL IS SET"; a 3-rung league plays three rounds, not four). The screen's mechanics live in git history (like the lob) if the ceremony is ever asked back. ## Session log: THE LEAGUES — the plaque moves mid-court, and the 94vw scrollbar (2026-08-29, same day, STAGING ONLY) Live review with Oliver on the pane, three finds: - **The plaque left the foot** — its solid panel sat exactly on the backdrop's blood pool, and LEVEL/LEAGUE were 8px footnotes. It floats CENTRED under the title now, on a translucent surface (rgba ink 0.48, radius 12, no border — the art shows through), with the wappen on top, the name, **LEVEL · LEAGUE in Bangers 27** as the announcement it is, the bar, and the goal line (warm, wrapping). The foot is back to PLAY MATCH + two buttons and the blood pool breathes. - **The phantom horizontal scrollbar** (Oliver spotted a sliver of h-scroll): TWO causes. The goal line's `white-space: nowrap` set the card's min-content past the viewport (the longest goal is ~380px) — it wraps now. And the ROOT cause predates the leagues: `.card { width: min(370px, 94vw) }` is CONTENT width beside 28px of padding, so the card overflowed every phone below 466px (0.94w + 28 > w) and #intro's `overflow-y: auto` computes overflow-x to auto. Now `min(370px, 100vw - 28px)` — desktop cap intact, phones exactly viewport-wide. - **The stylesheet carried a full duplicate block** (level strip + old plaque + bar tracks + career rules, twice) — leftovers of the mis-spliced ribbon edit that the .way dedupe missed. Removed; the dead #xpRow rules went with it. ## Session log: THE CLIMB — the arcade gets its identity back (2026-08-29, same day, STAGING ONLY) Oliver: "arcade verhält sich genau so [wie der cup].. die grenzen werden unscharf." Right — the phase-2 band clamp had reduced the arcade to the cup's own 3-4 bodies, loose. The two modes have sharp identities again: - **THE ARCADE IS THE CLIMB**: it opens at the very BOTTOM of the ladder (opp 1) and runs the whole EARNED club, clamping at your league's top (the clamp, its felled counter and its "NOBODY STRONGER / THE CUP IS THE WAY UP" walk-on lesson all stay). The marathon's length grows with every promotion — league 2 climbs 8 bodies, league 5 all 18 — and the FELLED board means something again. Poster sub: "THE WHOLE CLUB, BOTTOM UP · BLOOD ONLY". - **The tempo hangs on the OPPONENT'S territory now** (`config.leagueOfRung`, read by leaguePace): the low ladder plays its own shipped tempo and the world pulls away at each band border — the climb makes the five leagues legible as five territories. Cup unchanged in effect (its pool IS your band); the training court now practises a body at the tempo you would really meet it at (a deliberate revision of phase 2's flat league-1 training world). Netplay exempt. - **The climb picks its court freely** (among the unlocked): the CUP is the league fixture with the fixed home venue, the climb is the exhibition. start()'s forcing, the setup's single-row venue face and the summary's LEAGUE suffix are all cup-only again. - **The climb's setup face** (replaces the league panel there): THE CLIMB writ large, "THE CLUB FROM THE BOTTOM — N STAND · BEST RUN: M FELLED" (stats' own longest), no roster wall — the training picker already shows the club face-up. - **"RUNG" is gone from the game's copy** (Oliver: too exotic) — the training tiles say "No. 7", the club roster number. It only ever appeared there; everywhere else the word lives in comments. - Tests reworked: the climb walkthrough (opens at 1, clamps at 8 for league 2, felled honest) and the pace test (flat inside a band, steps at each border, netplay/gore exempt). Suite 124. ## Session log: THE LEAGUES — the setup grows a league face (2026-08-29, same day, STAGING ONLY) Two more from Oliver's screenshots: - **THE LEAGUE PANEL.** The generic MATCH SETUP screen showed the player block (tour/kit/skin) on the league modes too — redundant since the identity moved to the card. On zombie/cup the block now steps aside (syncSetup toggles, "01 · YOUR LEAGUE") for the league writ large: the breathing badge, "LEAGUE N — NAME" in the league colour, a felled tally line ("2 OF 4 FELLED · THE CUP IS THE WAY UP" / "ALL 4 FELLED — TAKE THE CUP"), and the band's roster as head tiles — felled bodies in colour with their ALL-TIME count (stats' per-foe tallies, key 'L'+index), the rest grey; the band's top rung wears the red border, it is the final. Every other mode keeps the player block (main.buildLeaguePanel). - **The ribbon grew** (13px type, taller band), per his call. - **The court section stops pretending on the league modes** (his catch: "eine Liga hat einen festen Court — eigentlich sollte gar keine Courtauswahl da sein" — right; start() had forced the venue all along while the list still offered a pick). On zombie/cup the section reads "02 · HOME COURT · SET BY YOUR LEAGUE" and shows exactly one ringed row, the league's court; the click handler refuses there. One authoritative loop in syncSetup paints on/lock/display for both faces — the first cut had the league filter ABOVE the generic repaint, which promptly undid it. ## Session log: THE LEAGUES — the polish round: the ribbon, the glow, the count-up (2026-08-29, same day, STAGING ONLY) Oliver's retest ("schon viel besser") with five polish items, all built: - **Every chevron is UPRIGHT now.** Bangers is drawn slanted, so `‹`/`›` read as italic on every screen (his "an vielen Stellen"). All `.chev` buttons and the plaque's arrow use Silkscreen's straight ASCII `<`/`>` instead — the announcement face keeps its job, arrows stop leaning. - **The plaque wears rounded corners (8px) and real padding** (12/14 against 8/10). - **THE WAY-UP RIBBON**: settled after four cuts as a HORIZONTAL band (Oliver's call) — red field, thick white Silkscreen with a light shadow, running in from OVER the left border and dissolving rightward through a real PIXEL FADE: an inline-SVG mask of 6px cells thresholded against a Bayer matrix, four density steps — the dither the game's own art would use, never a smooth gradient (and never a rotated pixel font, which was the diagonal version's sin: the one anti-aliased thing in the menu; the diagonal also hid a text-WRAP bug that made the band a red block). The mask eats band, bevel and shadow together. It shows on BOTH picker levels: the top-level ZOMBIE TENNIS poster carries it too (`tag` on the MENU row), so the click path to the promotion is marked from the first screen. - **Poster captions grew**: titles 12 → 15 bold, sub lines 9 → 10 and a brighter grey — "viel Text in sehr kleiner Schrift" was true of every banner. - **The card performs its entrance**: earned badges and the current crest BREATHE a subtle gold halo (`badgeGlow` keyframes, CSS-only — the parked menu loop is irrelevant to CSS animations; the dark slots stay dead, which is their point), both bars sweep from empty (0.7s eased transition, forced-reflow reset), and every number counts up fast — the XP line and all six ledger figures through one `countUp` easer (setTimeout, the bracket's own no-rAF rule; timers cancelled on rebuild so re-entry never races itself). ## Session log: THE LEAGUES — the plaque round: the meta speaks (2026-08-29, same day, STAGING ONLY) Oliver's first screenshot review, four items, all built. The theme: the meta layer existed but did not TALK to the player. - **No second bar** (his question, settled in discussion): a career bar moves ~2% a session late and demotivates more than it informs. Total progress reads as the LEVEL NUMBER + LEAGUE instead; the card carries the long form — "LEVEL 12 OF 50" and a thin CAREER bar (whole-curve xp, honest about how far the cap is). - **THE PLAYER PLAQUE** replaces the bare level strip on the splash: ONE button-styled panel — league wappen, name, "LEVEL X · LEAGUE Y" over the bar, a chevron that says it opens (the bare strip's tap was undiscoverable), and **the standing GOAL LINE**: "WIN THE GRAVEYARD SHIFT CUP TO ADVANCE" / "…BECOME CHAMPION" / "CHAMPION OF THE DEAD CLUB — DEFEND THE NAME". The badge COLLECTION deliberately stays on the card (splash real estate: the boxed-bar lesson) — now one DISCOVERABLE tap away. - **"How do I advance?" is answered in three standing places** (the promotion ceremony taught it exactly once, and only AFTER the first promotion — too late): (1) the plaque's goal line; (2) the arcade's own "what now?" moment — when the band's top body walks BACK on (`d.again`, set by the clamp), its walk-on modal carries "NOBODY STRONGER IS LEFT / THE CUP IS THE WAY UP" (pinned by the band-clamp test); (3) the tournament poster wears a corner tag **THE WAY UP** (`tag` on the MODES row, rendered by banner()) and its blurb says "YOUR LEAGUE'S ROUNDS · WIN IT TO RISE". - **The promotion ceremony's court tile is centred** (`margin auto` + `align-self` — the .mid column stretches block children, so the 74%-wide img sat left), and the death screen's XP line moved to +24/+36/+50 — the pulsing ★NEW BEST★ is B(20) drawn top-baseline at +2 and overlapped the +18 line whenever both showed. ## Session log: THE LEAGUES, PHASE 4 — the duel learns, tin tiers, the level is you (2026-08-29, STAGING ONLY) Phase 4, the last of the built plan. STAGING ONLY — the whole feature waits on Oliver's own phone test before merging to main. - **THE DUEL LEARNS.** The diagnosis held: in a duel the racket zombie played with every ladder term inert (chase collapsed to `racketSpeed`, read to flat `aiRead`, flub a flat 0.2, quality capped 0.55) and only hp stiffened — at `duelFirst 2 / duelEvery 3` that made it a weapon DISPENSER, the same inflation class the barrel economy was cured of. `gameplay.duelSkill()` (0 → 1 over `TUNE.duelRampGames` 24, on `G.runGames` — gore's own clock, the league never rekeys the director) now ramps its legs (`duelSpeedMax` +35%), read (`duelReadMax` 0.93), whiff (`duelFlubCut`: 0.2 → 0.08) and return quality (`duelQBonus`: 0.55 → 0.75). Game 0 is exactly the shipped duel (calibration rule). Losing already costs plenty (opponent game + runGames++ + the 5-body surge + no tin/heal) — now it can actually happen late. - **THE TIN TIERS** (settled: no starting loadout). `tinLadder()` (pure, tested): a barrel weapon joins the duel pot when the RUN unlocks it (as ever) OR when the LEAGUE says so — `config.TIN_LEAGUE` lasers at 3, rockets at 4 — and league 5 duels pay DOUBLE tins (`TIN_DOUBLE_LEAGUE`, the card says ×2N). "Weapons are only ever won" stays literal. - **THE LEVEL IS YOU**: `profile.bonus()` — level 1 = all factors 1 (shipped), scaling linearly to the cap: melee cooldown FLOOR ×0.75 (the ceiling untouched), parry reach/arm ×1.25, hurt grace ×1.35. Read at the dial sites (meleeCooldown, keyPressed's parry arm, zombies' parry window + face-offset, hurtPlayer). Applies everywhere the dials do — gore included, which is the cross-mode incentive the plan asked for. - **THE BOARD WEARS THE BADGE**: score rows carry `lg` (server clean() bounds it 1-5, default 1; context, never a sort key), `stats.submit` takes it as a parameter (profile cannot be imported there — cycle), main passes `prof.lg`, and drawRanks renders the little shield before the name. Old rows carry none and show none — no migration. - Tests 121 → 124 (duel ramp shape + its caps stay fallible, tin tiers incl. the double tin and the run-gate fallback, bonus() bounds + the grace landing on `G.hurtT`). Seeds fuzzed. Server round-tripped live (`lg: 4` stored, served, and drawn as the navy shield on the board). ## Session log: THE LEAGUES, PHASE 3 — the character card, badges, the skill trail (2026-08-29, STAGING ONLY) Phase 3. STAGING ONLY, like 1 and 2. - **THE CHARACTER CARD** (`#card`, main.buildCard): the splash's LEVEL STRIP is the door (tap it — it wears `cursor: pointer` and nothing else changed on the splash). Name as the screen title, the live sprite (drawLook, third canvas on refreshLook), the current league's crest + name in the league colour, LEVEL in Bangers over the gradient bar with "N XP · M TO LEVEL X", an HONOURS row, and THE LEDGER (two .figs rows off stats.read per mode: kills / multi-kills / deaths, best run / most felled / runs). EDIT PLAYER borrows the identity screen and returns to the card (`playerReturn`). Deviation from the plan: no death-screen entry — the death screen already carries the XP moment inline, and its tap is the restart. - **The honours row is a COLLECTION**: all five league shields plus the champion's laurel always visible, earned bright, unearned dark (`filter: grayscale brightness`) — the empty slots are the incentive. A shield is earned by FINISHING its league (being past it); league five's lights with the champion crown, since there is no league six to be past it. - **Badges rolled via OpenRouter** (one sheet on #FF00FF, cut by connected components + chroma key, 4x NEAREST downscale): `badge_l1..l5.png` (terracotta / slate / grass / navy-ice / marble-gold shields, each with the crossed-rackets motif and a chip of decay) + `badge_champ.png` (gold laurel + cup). DOM `` only — not in SPR, nothing draws them on canvas yet. - **THE SKILL TRAIL**: `LEAGUES` rows carry `color` (each riding its court's palette), and the player's OWN sprint dust wears it — `dustPuff` grew an optional colour, the mover's emit passes `skillDust()`. League 1 keeps the shipped court dust: the tint itself is a progression reward. Read off the profile (not `G.league`), so the gore mode shows your colour too. The aura stays made of MOTION, never paint — the full-body glow idea was dropped against the painted-layer veto. - Pane-verified: card at league 5 (four bright shields, dark L5 + laurel, ledger numbers, edit round trip). Suite unchanged at 121 — the card is DOM, the dust is a colour. ## Session log: THE LEAGUES, PHASE 2 — bands, pace tiers, courts, the promotion (2026-08-29, STAGING ONLY) Phase 2 of the plan. STAGING ONLY, like phase 1. - **`LEAGUES` in config.js** — five bands tiling the 18-rung ladder exactly (1-4 / 5-8 / 9-12 / 13-15 / 16-18, the club's own hierarchy), each with its home court: clay → hard → grass → gloom → **members** (new). `leagueOf(lg)`, and the ladder-shape test now pins the tiling. - **`CUP_ROUNDS` is GONE; the cup carries its own rounds** (`cup.rounds`, stamped by startMatch from `cupRoundsFor(G.league)`). A league band is 3-4 rungs, so the draw is WITHOUT REPLACEMENT: non-final rounds carry the whole sub-band, `cup.played` (and buildBracket's `used` set) stop a body being dealt twice, the final is always the band's top rung — "only the finale is settled" survives the narrow bands. 4-rung leagues play four rounds, 3-rung leagues three. - **`G.league`** (state, PRISTINE 1) is stamped by startMatch — netplay and training stay league-1 worlds. **`gameplay.leaguePace()`** = `1 + (league-1) * TUNE.leaguePace` (0.06) multiplies ball pace on BOTH rackets (doHit, both serve paths) and `rzChaseSpeed`, so the world speeds up as one piece and the reach tests keep believing the legs. League 1 IS the shipped game (factor 1.0 — the calibration rule); league 5 plays ~24% faster. Everything non-match is factor 1: gore keeps its own director. - **The arcade opens at its band's bottom rung and CLAMPS at its top** — past it the league has nobody stronger and the top body walks back on; the cup is the only way up. `d.felled` counts bodies now (opp is a band position, `opp - 1` stopped meaning anything — killPlayer, pagehide and the scoreboard slot all read felled). - **Courts lock to the leagues**: a court unlocks for EVERY mode when you reach its league (settled reward formula). Rows keep their tile visible but answer no tap and say the price ("LOCKED — REACH THE OFFICIALS"); syncSetup repaints locks on entry; a remembered-but-locked pick falls back to the league court. League matches force the league court in `start()`; the footer says so plus "LEAGUE N". The training picker only offers rungs up to your band's top. - **THE MEMBERS' GROUND** (Oliver's pick): new COURTS row — brightly decayed prestige, dead grey-green lawn (fastest bounce in the table, finishing the pace ladder), pale marble, ivy, faded gold; borrows clay's crowd/ads with `dim` 0.15. Tile rolled via the famroll OpenRouter route (style ref = court_grass tile, 4x NEAREST downscale + quantise), SPRV 148 → 149. - **THE PROMOTION CEREMONY**: cupWon (non-train, cup of your CURRENT league) moves `prof.lg`, parks a ceremony in the profile module (consumed-on-read, survives restartRun), and maybeRestart plays it before the name prompt: PROMOTED. / league name / the new court's tile / "OPEN IN EVERY MODE" / WALK ON. League 5 crowns instead (`prof.ch`, "CHAMPION."). The cup summary's big line is league-aware now: 'CHAMPION OF THE DEAD CLUB.' belongs to league 5 only, lower cups say 'THE CUP IS YOURS.' - **Fix found live**: the scoreboard's cup slot indexed `cup.rounds[cup.round]` one past the end on the trophy frame (cup.round is incremented before cupWon is reached) — clamped. - **XP bar wears a gradient** (Oliver, mid-session): green → acid → gold, splash strip and the canvas bar on the death screen. - Tests 118 → 121 (band tiling + per-league cup shape, promotion + ceremony parked, champion crown, arcade band clamp + felled, pace-tier scaling and its exemptions; the tournament walkthrough now also asserts no body is dealt twice and the promotion fires; freshSplatter resets the profile league so a won cup cannot leak league 2 into later tests). Seeds fuzzed. Pane-verified: locks, forced court + league footer, league-2 draw, full cup → trophy (+1512 XP · LEVEL 6 → 8) → PROMOTED ceremony → league-5 arcade opening vs THE BRUTE on the members court. ## Session log: THE LEAGUES, PHASE 1 — profile, xp, the identity screen (2026-08-29, STAGING ONLY) Phase 1 of the plan below, built exactly to it. **STAGING ONLY for now** (Oliver: he tests himself before the wider group) — do not merge to main until his verdict. - **`src/profile.js`** is the profile: `{xp, lg, ch, setup}` in localStorage `splatterProfile`, mirrored to the server (`/api/profile`, keyed by stats' existing client id). Name/tour/kit/skin deliberately KEEP their existing homes and are only mirrored outbound, so nothing has two owners. The mirror never goes backward on xp (server-side guard) and `pull()` adopts it only when ahead — a wiped phone gets its identity AND its number back. - **XP hooks are deliberately few**: `advancePoint` (the one place every mode's point converges — points and games cannot be missed or double-counted), the `killZombie` funnel, duel win, cup round, cup won. `award()` carries the no-stakes guard ITSELF (drill, bench, testMode, training court), so a call site cannot forget it. Curve: `80·(l−1)^1.8`, cap 50 — first levels inside the first run, late ones sessions apart. All dials in `profile.XP`. - **UI**: the splash carries a LEVEL strip (#xpRow, filled by refreshSplash); the death/summary screen shows `+N XP · LEVEL a → b` plus a progress bar (render.js, beside the best-run lines). A mid-run level-up announces as a CARD (popup policy: never a modal on a live court). - **THE IDENTITY SCREEN** (`#player`, first `PLAY MATCH` tap only): name prefilled with a ZOMBIE NAME (`profile.zombieName()`, two-part roll, always ≤12 chars and board-legal), a die button rolls another; tour/kit/skin pickers write the SAME picked* state and localStorage keys the setup screen reads. **`syncSetup()` (called by `go('setup')`) repaints the setup rows from picked\*** — without it the rows only tracked their own taps and showed the boot-time selection after an identity-screen change (caught live in the pane). - **Server**: `/api/profile` GET/POST beside the scores API, same stance (whitelist re-derive, bounded, atomic write, 5000 profiles cap, oldest-touched evicted). `profiles.json` beside `scores.json` under `/data` — the volume that already exists. - **Drive-by fix**: `pagehide` now returns on `G.drill` — drill kills leak into the stats run bag (tally sits above the drill hook in killZombie) and a tab close mid-drill banked them as a gore run. Death was already guarded; the tab-close path was not. - Tests: +4 (curve shape, the award funnel + every mute, run-delta reset, name legality), suite 118, seeds fuzzed. Pane-verified end to end (identity flow, POST 200, death screen with +16 XP and the bar; NOTE this pane session has rAF dead again — `T.step(1/60)` loops drove the clock). ## THE LEAGUES — the meta-progression plan (2026-08-29, AGREED; PHASES 1-4 BUILT ON STAGING — see the four logs above; phase 5 deliberately parked) Worked out with Oliver in discussion, decisions settled below. The goal in his words: an incentive to keep opening the game beyond beating the next opponent — levelling through play, with a clearly FELT improvement in gameplay. This is the plan of record for the feature; build it phase by phase and record verdicts here like any other session. ### The two axes, and the one rule that protects the shipped game - **The LEAGUE determines the world**: pace tier (ball pace + opponent speed), opponent band, court. - **The LEVEL determines you**: small honest buffs through existing TUNE dials — serve pace cap, hard-serve scatter, parry window, melee cooldown floor, `hurtGrace`. Never new gestures, never gesture removal. - **THE SHIPPED GAME IS LEAGUE 1** (settled explicitly): today's tuning is the floor, progression extends UPWARD. No deliberately-degraded start, and nothing that exists today gets locked behind a level — carving the current game into locked pieces is a silent regression of a tuned build. League 1's opponents are tuned a notch ahead of a fresh player; the level grinds you into range. - **The tournament gate is a skill check, not a timer**: winning the league's cup promotes you. The level makes the cup EASIER, never POSSIBLE — a genuinely good player may climb early. ### Decisions, all settled with Oliver 2026-08-29 1. **Leagues are BANDS of the existing 18-rung LADDER** — no new rosters. The league cup is the existing cup machinery drawing from the league's band (CUP_ROUNDS already draws from bands). Five leagues; THE CHAMPION is the final of league 5. Exact band edges are Phase 2 tuning. 2. **XP comes from ACTIVE PLAY EVENTS** (points played, kills, games — practically playtime, but idle-proof), never wall-clock. **Losing pays too** — a hard league must be a grind, not a wall. The training court pays nothing ("NOTHING AT STAKE" is its settled motto; otherwise it is the AFK farm). Level curve: fast early (the hook), slow late (Oliver: levelling must not be easy). Iron rule: every session ends with a visible delta — bar moved, badge, milestone. 3. **Courts ride the leagues, and the bounce table already agrees**: 1 TERRE ROUGE (slow clay), 2 THE SLAB (true hard), 3 CENTRE COURT (fast grass), 4 THE NIGHT COURT, 5 **THE MEMBERS' GROUND** — new court, the Dead Club's decayed place of honour: dead grey-green lawn, cracked lines, ivy over marble, faded gold on the boards. Brightly decayed, distinct from gloom's dark. Built like gloom was: a COURTS row + tile + palettes, borrowing a stands sheet with dim/grading before we roll a fourth crowd grade. League matches play the league's court; **free court pick stays in classic/netplay/training**, and **finishing a league unlocks its court for all modes** (settled — the unlock is the reward formula). 4. **Gore coupling is TIN-TIER, no starting loadout** (settled): the level/league determines the POOL the duel's tin pays from (placeholder ladder: lasers join at league 3, rockets 4, double tins 5 — numbers are Phase 4 tuning). "Weapons are only ever won" stays literally intact and the world board is not devalued by starting inventory. **The duel gets fixed FIRST**: at `duelFirst 2 / duelEvery 3` with hp-only stiffening (`racketHpPerGame` 0.12) it is a weapon dispenser, not a gate — the same inflation class as the old barrel income. The racket zombie gets the ladder's tennis brain (pace, chase, flub, THE READ scaling), so a duel can be LOST and a won tin is worth something. While in there: establish what losing a duel actually costs; if the answer is "nothing but the tin", that is half the diagnosis. Gore's own difficulty director stays keyed on `G.runGames` — the league buffs YOU, it does not rekey the director. Defensive level buffs (melee floor, parry, hurtGrace) apply in gore; **board rows carry the league badge** — context instead of score normalization, and an upset is visible. 5. **No full-body aura** — that is the painted-layer pattern the player-blood veto already killed. The skill colour lives in: a TINT on the existing sprint trail/dust register, the character card, and the walk-on/scoreboard presentation ("CHALLENGER · LIGA 3"). 6. **The character card**: big sprite, level bar, league badge, trophy icons (rolled through the sprite pipeline), totals from stats.js (the data already exists). Reachable from the menu AND the death screen — the death screen is where meta-progress absorbs permadeath ("run over, but +340 XP, level 7 → 8"). First-class typography per the polish rule. 7. **The profile is backed up SERVER-SIDE before the grind clock counts.** Hours of investment on localStorage alone dies with cleared browser data. Client ID, name ownership and the /data volume all exist; a profile blob beside scores.json is the small addition. 8. **Deferred, on purpose**: skill matchmaking (a queue at our playerbase is empty, and an empty queue is worse than none — show the league badge in netplay instead; revisit with players), and a daily bonus (first-win-of-the-day; smells of mobile dark patterns — Oliver's call later). ### The phases - **Phase 1 — profile + XP + bar.** One profile (localStorage + server backup), events pay in, level bar in the menu and on the death screen. NO gameplay effect yet: the clock must visibly tick first. - **Phase 2 — leagues.** Ladder bands, pace tiers, court per league, league cup as promotion gate, court unlock. The roster fine-tuning lives here; phone-verdict heavy. - **Phase 3 — character card + badges + trail tint.** The presentation. - **Phase 4 — gore coupling.** Duel fix first, then tin tiers, defensive buffs, board badge. - **Phase 5 — later.** Matchmaking, daily incentive (both deliberately parked, see 8). Open tuning questions for the build (not decisions): what a pace tier IS mechanically (one global pace multiplier vs per-dial), the XP amounts and curve numbers, the five band edges over 18 rungs, the tin-tier ladder, and the duel-loss cost. ## Session log: THE READ, the walk-on modal, the training court, movement reads (2026-08-28) Four items from one phone report. PHONE VERDICT PENDING on 2-4; item 1 got its first verdict the same day — *"die Gegner sind jetzt etwas zuu einfach"* — and was turned down a notch: `readHabit` 3 -> 4, `surpRead` 0.85 -> 0.6, `surpSlow` 0.45 -> 0.3, `surpFlub` 0.35 -> 0.22. A surprise is an opening the player still has to play, not a point handed over. The whiff-window test pin moved 0.25 -> 0.15 with it. ### 1. THE READ — the twist for the post-COACH wall Oliver: past the HEAD COACH *"the opponents are very fast and barely make mistakes any more — we need a twist, maybe a kind of cognitive fatigue when you make the same move ten times and then surprise them."* Built as HABITUATION, his own idea with the numbers made playable: - **doHit buckets every aimed player rally shot into three lanes** (target x at ±1.1) and keeps a same-lane run on `G.match.laneRun`. `TUNE.readHabit` (3) same-lane shots = the habit is learned. - **One more of the same lane is `expected`**: the opponent's `aiRead` climbs toward `TUNE.readAnticipate` (0.97) — spam feeds the machine, so the late ladder punishes one-note play. - **Breaking the habit is `surprised`, for that one flight**: the read collapses (`surpRead`), the chase speed drops (`surpSlow`, applied inside `rzChaseSpeed` so the intercept planner and the early-fall test believe it too), and `surpFlub` lands on top of a whiff rate the champion had reduced to almost nothing. A "WRONG-FOOTED." flash announces it (rungs past ~20% only). - **Everything scales with the rung** (`learnK`, (opp−1)/(LADDER−1)): the shambler never learns, the champion — "HE RETURNS EVERYTHING" — is exactly the one you beat by setting a habit and breaking it on the big point. Solo match only (netplay's opponent is a human), rally lanes only (serves keep their own game — open: should serve placement feed the same book?). The one-ball states are cleared at the racket and at every point end (`matchReady`); a fresh walk-on resets the book. Tests pin the state machine (real swings through `keyPressed`) and the dial relationships (a wrong-footed champion runs slower than the head coach at full stride). ### 2. THE WALK-ON IS A MODAL (tournament + arcade + training) Oliver: *"die Intros der einzelnen Spieler bitte als richtiges Modal, das man wegtappen muss — man kann dann wirklich lesen was da steht, und schnell wegklicken wenn man es schon 100x gelesen hat."* `matchOpponent` now calls `showModal` (head icon `z_head`, the punchline, the cup's round line) instead of the timed 4 s card. The walk-on court is guaranteed-calm (the mop beat has just ended), so the popup policy allows it; the freeze holds the 1.6 s intro bar until the tap. The mode-rules popup fires BEFORE `matchOpponent` in `startMatch` so it lands first and the opponent queues behind it. Netplay untouched (no walk-on there at all). The test harness's `passMopBeat` now taps the walk-on away like a player would. ### 3. THE TRAINING COURT (`MODES` id `train`, third kid of ZOMBIE TENNIS) Oliver: *"ein Trainingsbereich — Auswahl des Gegners, ein Spiel, tap to replay, bis man keine Lust mehr hat."* One flag (`d.train`, set by `startMatch(false, null, rung)`) on the whole match machinery: - SERVE UP routes to a picker screen (`#train`, built from the roster like the boss picker, the same `z_*head` crops the draw uses); tapping a rung IS the start — tap-advances, like every other screen. `mode_train.png` rolled in the banner style (golden-hour practice court, ball basket, cones, no text) via the famroll OpenRouter route. - The rung is PARKED: `d.opp` never advances, a felled body walks back on as itself, and the walk-on modal (item 2) is the "tap to replay" gate for free. Between reps the blood resets silently (no WOUNDS TREATED ceremony — a popup per rep is the tap-tax the mode avoids). - NOTHING BANKS: no `G.runGames`, no `endRun` (pagehide guarded), and bleeding out is a modal ("IT BLED YOU OUT") plus a fresh pool, never the death screen. The quit chip is the exit. - The board's progress slot reads `TRAINING · `; the setup footer says "NOTHING AT STAKE". ### 4. MOVEMENT READS — dust and speed ghosts (feel-tunable, verdict pending) Oliver: *"die Bewegung der Spieler optisch anreichern — motion blur / particles / shader, hier bin ich auf deinen Input angewiesen."* Two cheap effects in the existing registers, no shaders: - **Sprint dust**: feet crossing `TUNE.trailMin` (3.6 m/s — above every shamble) kick one per-court `dustPuff` per `runDustEvery` metres. racketAI's chase and the player's mover (auto-run, horde steer) both emit; the mover measures its travel AFTER the clamps so a body pinned at a bound reads as standing. - **Speed ghosts**: `drawZombie`/`drawChar` draw two alpha ghosts sampled back along the body's own velocity (`trailK` s × 1 and 2) — the modal masthead's speed-streak trick applied to bodies. Only genuine sprints qualify; never during a named frame (`showFrame` — a collapsing corpse must not smear on stale velocity). racketAI now records `vz` beside `vx`; the player's `P.vx/vz` are written by the mover and zeroed outside it. Both are panel-tunable (`trailMin` 0 kills both). Not tried, on the shelf if these fail the phone: a one-frame lean/skid sprite on hard direction changes (needs drawn frames, per the draw-frames rule), and court-colored skid streaks on the ground behind a hard stop. **First verdict, same day** (*"dezenter, schneller ausfaden — teilweise bleiben die doppelten ghosts länger sichtbar"*): the lingering was a real bug — racketAI's `!incoming` return kept the last chase's `vx/vz` on the body, so the ghosts trailed a zombie that had already stopped; the velocity now dies with the chase (and a test pins it). The look was then halved with it: ghost alphas 0.22/0.10 -> 0.11/0.05, `trailK` 0.05 -> 0.035. The trail must read as motion, never as residue or a second body. ## Session log: THE LOB (BUILT, THEN CUT) AND TWO THINGS THE MULTIPLAYER TEST FOUND (2026-08-22, same day) ### THE LOB WAS BUILT AND REMOVED THE SAME DAY — and the reason is the useful part Oliver: *"when you're close to the net in a duel with another player there should be a gesture allowing to make a ball flying high from below (lupfen)... this likely needs to be discussed."* Three candidates were put to him — (a) slow drag vs fast flick, (b) a down-then-up hook, (c) let CONTEXT arm it: the forward flick keeps its one meaning ("put it deep behind him") and the game picks the trajectory that does that, which with a man at the net is over his head. He picked (c): *"i think this is the easiest. no additional gesture to learn."* It was built, measured and shipped. It worked exactly as specified — armed at `TUNE.lobArm` 7 m (the distance at which a lob peaking at `lobApex` still passes over `CONTACT.maxY`), pace derived from the height so the arc was the same from the baseline and from mid-court, target deeper than `SHOT_DEPTH.deep`, and the word LOB popped at the contact to say which shot had just been asked for. **His verdict, within the hour: *"all good but the lob. please remove it — this is totally unpredictable."*** It is reverted in full: `oppAtNet`, `farBody`, `TUNE.lobArm`, `TUNE.lobApex`, `SHOT_DEPTH.lob`, the branch in `doHit` and its two tests are gone, and the forward flick is the smash it always was. **THE LESSON, AND IT IS A RULE NOW: a gesture whose MEANING depends on where the opponent is standing is unpredictable, however well the shot itself is tuned.** This is the same failure the input layer already names in its own words — *"No timing window and no invisible mode: the direction you moved is the thing you asked for"* (the slingshot's sidestep-vs-pull comment). The written comparison put that objection against option (a) and let (c) past on the argument that context can never mis-fire. It can: the player does not track where the far body is standing while a ball is coming at him, so the same thumb movement produced two different shots for reasons he was not watching. The word at the contact told him WHICH shot he got, but a label after the fact does not make a gesture predictable — it only explains the surprise. **If the lob is ever asked for again, it needs its own gesture** (option (b), the down-then-up hook, is the only candidate left that cannot be confused with an existing one) — and the mechanics above are all still correct and are recorded in the git history at 4f4534e, so the shot itself is a half-hour's work. The question was never the ballistics. ### "THE OPPONENT SEES THE BALL REALLY FAST — THE PLAYER BARELY SEES THE SPEED INCREASE" Measured first, because the report has two possible causes and only one of them is a bug. **The ball is the same ball on both phones** — `netSendShot` copies vx/vy/vz/g verbatim and the receiver mirrors them, no re-simulation. What differs is the CAMERA: it sits behind your baseline, so your own ball's screen speed decays into the distance while the same shot expands toward the man receiving it. That asymmetry cannot be fixed by changing pace. The pace ladder, measured: **serve 49.2 plain / 50.8-52.4 with the hard flick** (the +9 m/s `serveHard` asks for 56-58, and `serveArc`'s net-clearance loop takes 10% of it back in one cut); **rally 19.2 + 14.4×timing, ×1.42 for a smash at flush timing** — so the smash is a real 42% when you time it and nothing when you do not, and the hard SERVE is only ever ~3-6%. Nothing about the pace was changed. Three feedback channels were added instead, all on the striker's side, none of them touching a ball's physics: - **The ghost streak now reads PACE.** It was three ghosts at every speed, so it said fast/not-fast and nothing else; it is 3 to 8 now, sampled every frame, so a quick ball is both longer and more spread out. A lob stays clean. - **The crack is pitched by pace** (`strikeRate`, 0.93 to 1.09 — past a tenth the sample stops sounding like the same racket). - **A fast ball kicks the camera** (`strikeKick`, up to ~2.2 px for 0.12 s, YOUR racket only, and never over a shake already running). **Still open, and it is Oliver's call.** If he wants the hard serve to actually BE harder, the knob is `serveArc`'s loft margin: it charges `0.06 + descent×0.04` (about 0.46 m of extra net clearance on a fast serve) against a sampling error the sim does not really make any more — the net test in `update()` already interpolates the crossing height, and semi-implicit Euler's real lag at the tape measures ~2-17 cm. Cutting that margin gives the hard serve its full +9 back (58 vs 49, a difference you could see). Not done unasked: a serve into the tape is worse than a slow one, and that margin exists because balls were clipping it. ### THE DEATH MATCH: THE BURST IS THE DEATH, AND ONLY BLOOD IS Oliver: *"each player has 8 blood points but the player explodes for the winner already at 0:40 — the winner sees the zombie explode but the player on the other side is not dead yet. this needs to be aligned."* Two clocks were running side by side: **a game is four points, a life is eight**, and `matchGameWon` burst the friend's body on the game. Both were true on their own phone and they disagreed with each other. - **A won game is only a game now** (netplay branch of `matchGameWon`): the flash, the serve change and the heal it pays, then the next point. No burst, no walk-on, no mop beat — there is no roster body to walk back on in a death match anyway. - **The burst moved to the death**, in `netMatchWon`, which fires on the `dead` message the dying phone sends when its own blood runs out. Both sides are then agreeing about the same event. - **The summary waits `TUNE.netWinBurst` (1.4 s) for the gore to fly.** `update()` freezes the whole world on `G.done` — no particles — so setting it on the same frame as the burst hangs a still frame of an explosion behind the trophy screen. Ticked in `tickMatchFall`, like every other beat in beats.js that has to outlive the frame that started it. - **The friend's bleeding tracks THEIR blood.** `bleedO` read points-won-this-game, which resets every game and ignores the heal — a friend two points from death looked unhurt. Their hp now rides the 10 Hz `pos` stream (one number; a dropped one corrects itself 100 ms later) and the flow reads it. The old count survives as the fallback for a build that sends none. ## Session log: THE SERVICE RULES ARRIVE IN THE OTHER MODES (2026-08-22) Three notes from Oliver, two of them fixed here; the third (the lob) is under discussion in OPEN. - **A RECEIVER STANDS BEHIND HIS BASELINE.** *"when the opponent serves the player does not stand at the baseline — but that's how it should be."* `setupPoint`'s receiving marks were z 22.5 near and 1.2 far — over a metre INSIDE the court on both sides, which is where you stand to attack a second serve, not where you wait for a first one, and next to a server drawn behind his own line it read as a man who had wandered in. They now mirror the server's depth (24.2 / -0.43). It is one line, so every mode that serves through the tennis branch got it: classic, gore's opener, the match, and netplay. The duel is unaffected by design — it takes setupPoint's splatter early-out and is always your serve, from wherever you are standing. - **A MISSED SERVE BUYS A SECOND ONE.** *"when serving results in an out there should be a second service... the umpire should announce 2nd service and the player must get another chance."* True in tennis mode since forever, and **absent from every other mode**: the duel, the match and the death match had no service rule at all, so the first serve was judged as an ordinary ground stroke and a serve long or into the net handed the point away — in the match that is a point AND blood, on the very first ball of it. The out branch of `onBounce`'s interlude block now routes a serve to `fault()` instead of `resolveInterlude`, so the second serve, the HUD's SECOND SERVE line, the umpire and the double fault all come for free. Three things it dragged out with it: 1. **`fault()`'s double-fault branch called `pointTo`** — TENNIS scoring — which in an interlude would bank a number and skip the mode that owns the court (blood, the fall, the ladder). It routes through `resolveInterlude` there now. Nothing had ever reached that line from a duel or a match, because those modes could not fault. 2. **`startDuel` never reset `G.serveNum`.** Harmless while a duel could not fault; the moment it can, a fault in one duel arms second-serve in the NEXT one — the exact death loop `matchReady` already carries a note about, one mode over. 3. **The match puts the body back on its receiving mark for the second serve** (`placeReceiver`, split out of `matchReady` because that call resets the serve number a fault has just spent). Not in netplay: the called ball is left rolling there, and `matchReady` would freeze it. - **THE BOX IS STILL NOT ENFORCED IN THE MATCH, deliberately.** Classic checks the service box; the match's line call only ever checked the court, and a deep-but-in serve stays legal there. That is the concession the mode was tuned around (see the "classic tennis gives double fault very often" note in `keyPressed`) and this change is strictly more forgiving than what shipped. Enforcing it is a one-line change if Oliver wants real service boxes in zombie tennis — ask before doing it. - **One new recording: `second_service.m4a`** (`say -v Daniel -r 168` -> `afconvert`, the existing pipeline). It plays as a SEQUENCE with the fault call — `voiceSeq(['fault', 'second_service'])` — because `voice()` drops any line that would start while the umpire is still talking, so a second `voice()` on the next line would have been silently swallowed. "Fault. Second service." ## ZOMBIE TENNIS — the mode, end to end (folded from HANDOFF-MATCH.md, 2026-08-17) `HANDOFF-MATCH.md` was a temporary file written when a session ran out of context mid-slice. The slice is finished; this is what survives it. The reasoning behind the numbers is in the drill/match session logs (passes 13-23) further down — this section is the contract, not the diary. **What it is.** Real tennis against the racket zombie. One game per opponent, tap-and-timing controls, the deuce/ad serve positions, and **the score IS the body**: each point you win takes a quarter of its pool and walks it through its wound stages, and the game-winning point fells it so a fresh opponent shambles on. Its points draw your blood (`matchPlayerHp` 8, `matchHeal` 2 per game won). **The measure is OPPONENTS FELLED** — in play (`OPPONENT n · m FELLED` under the board) and on the death screen. Entry: the setup screen's three modes (`config.MODES`), or the back room's MATCH TEST, which still works and is the fastest way in for a test. It runs on a PERMANENT interlude. `G.interlude` is what makes serving tennis-like, hides the rack and wakes `racketAI`; the only structural change is `resolveInterlude` feeding `matchPoint` instead of ending anything. **So `G.mode` is `'splatter'` here, never `'tennis'`** — every mode gate written as `G.mode === 'tennis'` silently excludes the match, and that has been the shape of four separate bugs (the umpire, the boy, the serve, the flick lesson). `rallyFeel()` is the honest test for "a real rally mode": classic AND the match, and NOT gore's one-game prologue. **SCORING IS BLOOD-ONLY** (Oliver's pick of three). The run is one long bleed across opponents; losing a game costs nothing but the blood already spent, and heals are how you last. *Model B — tennis-true, a lost game ends the run — was rejected for now and is still worth one playtest; it might be the mode's real personality.* Context worth keeping: Oliver believed a lost match killed him ("i really die losing one match"). It did not — he was bleeding out during deuce wars. **That misreading was itself the bug**: blood got a pip readout beside the board because the vignette says "worse" and never says "how many". **The ladder escalates in `racketAI`, not in hp** — the pool is flat. Four dials, each climbing per rung: speed (`matchSpeedBase`/`Per`), whiff (`matchFlub`/`FlubKeep`), width (`matchWideBase`/ `WidePer`), pace (`matchOppPace`/`PacePer`). Player side: `matchAssist`, `matchReach`. **THE CARD — ten bodies, one list read twice.** `config.OPPONENTS` is the narrative AND the difficulty curve: the further you get, the further up the club's hierarchy the corpse across the net used to be. SHAMBLER, BALL BOY, GROUNDSKEEPER, STEWARD, RUNNER, LINE JUDGE, CLUB PRO, THE BULK, DIRECTOR, CHAMPION — each with a name, a `short` for the board, a line, a sprite family and a personality that is DIALS (pace, width, flub, speed scalings on the numbers `racketAI` already reads). **The ramp is asserted, not assumed**: a test walks all ten and requires the whiff rate to fall every rung, the names to fit the board's 8-char slot, and speed never to step meaningfully backwards. Speed may ZIGZAG by character (5 is the RUNNER, 6 paints lines instead of sprinting); the whiff rate is the dial the ramp actually rides on. **THE WOUNDS PLAY** (2026-08-17). `config.matchWound` reads the same `racketStage` the SKINS are picked with and returns `{reach, flub}`, which `racketAI` spends on its contact radius and its whiff rate: a one-armed opponent covers less, a headless one misses more. Deliberately SMALL — 6% reach and 17.5% whiff PER STAGE (Oliver: "not too pronounced") — texture under the ladder, never a second difficulty curve, and a test pins that ceiling. Unhurt returns exactly `{1, 1}` and the whole thing is gated on `G.match`, which is what keeps the gore duel's racket zombie untouched. Note the arithmetic: a quarter off a full pool is 0.75, so **point one crosses no stage** — the dials move on points two and three, in step with the art. **THE FALL.** Point won → the body COLLAPSES → drags itself up minus the limb (`beats.tickMatchFall`, on its OWN clock beside the horde's warning — a beat that moves the phase under a latch strands it, the mgT lock again). The 'over' beat it also sets is deliberately longer than the fall, so it cannot expire into `setupPoint()` mid-collapse and hand you a serve over a body on the ground. The contract: **the three collapse frames wear the stage it STILL WAS, the rise wears the stage it has BECOME**, and it only plays when the stage actually moved. **THE FLICK IS THE MODE'S ONE ACT OF SKILL, and it used to lose the point.** Oliver: "8/10 times the ball lands out". It was arithmetic. The singles line is 4.115 m; an aimed shot targeted a flat width and THEN added `err()` on top — up to ±`aimError` at quality 0 — so a flicked return landed as wide as 5.7 m, while an UNAIMED return (target 0 + the same scatter) was always in. **Aiming was the thing that lost the point.** `TUNE.flickReach` scales the aim width by the same quality the scatter shrinks with, so the two can never sum past the line, and a test asserts that sum across the whole quality range rather than sampling rallies. **Not named `aimReach`** — that key already exists further down TUNE (the slingshot's aim ray) and the duplicate silently won, which a test caught. The placement ring carries `q` and reads as the flick's receipt: tight and hot when your timing earned the full width, wide and dim when you barely got a racket on it. **...and it is TAUGHT** (2026-08-17), because the ring is a receipt, not a lesson — you only get one if you already knew to swipe. The ghost hand mimes it on the apron in the SERVE STANCE, in the very demo the slingshot lesson plays, armed by a run of returns that left with no aim on them (`G.flatStreak` / `flatCount`, `TUNE.flickHintAfter`) and disarmed by the first aimed one. **A gesture is read in a pause, not in a point** (Oliver: "it is displayed mid-play.. please rather show it in one of the pauses") — the first cut rode the incoming ball, at the moment the player has the least attention to spare. Touch only, rally modes only, and the tap lesson wins if both qualify: teaching placement to someone who cannot reach the ball is the wrong lesson. **...and its LENGTH is its strength** (2026-08-18). Oliver: *"the flick to aim sometimes is applied very strong, sometimes it barely does anything. this leads to a lack of agency, the player can't really tell if there was skill or luck."* He was right and it was the gesture reader, not the feel: the rally flick was quantised to THREE LANES (`ax`, a swipe past 0.38 of its own length sideways), so two neighbouring gestures differed by the whole court and nothing lived in between. `gameplay. swayOf` reads the swipe's length as a continuous 0..1 (`TUNE.flickMin`..`flickFull`, px) exactly as the SERVE already did, and `doHit` spends that instead of `ax`. Keys have no length, so they keep the full lane, and the AI's aim (no `mag`) is untouched. **...and the out was tightened once more** (2026-08-18, *"make it a little harder to hit the out"*): `flickReach` 0.62 -> 0.52, halving the worst-case rate from ~15% to ~7% and pinned by the test. The shape is unchanged — a perfect flick still never sails, a badly timed full swipe still can — but a mistimed one now lands NEARER THE MIDDLE rather than nearer the line, which is the only half of that trade a player who cannot see his own timing can do anything about. **`flickReach` is the out dial**; `aimError` is shared with the opponent's returns and with every aimed shot in the gore modes. **This is also what hands the OUT back to the player.** Oliver: *"the number one reason i die is that i myself keep firing balls into the out."* Going out was never his choice — every aimed shot took the FULL width and then `err()` on top, so the dice decided. Width follows the swipe now, so the risk band has a floor the player can stop short of: **below `(line - aimError) / (flickWidth * flickReach)` ≈ 0.7 of a full swipe, the worst mistiming in the game cannot put the ball out**, and above it the old risk curve is untouched. The tested contract from the pass before this one still holds — a perfectly timed flick never sails, a badly timed full one can. **THE AUTO-RUN OWNS THE LEGS, so the drag does not** (2026-08-18, Oliver: *"could it be that when playing touch on mobile zombie tennis and tennis i am still able to manually move by dragging? in autorun this must be disabled"* — it could, and it was). It was not a harmless second option: a manual axis SUPPRESSES the assist (the `!mx && !mz` gate in the movement block), so holding a drag traded a full-speed run to the intercept point for a `0.35x` shuffle aimed at wherever the thumb went rather than at where the ball will be. Walking was strictly worse than standing still, which is most of *"i tend to not be able to reach the balls"*. `input.js` stops writing `touchAxis` under `rallyFeel()`; the drag keeps its one rally meaning (release it quickly and it is the aimed swing), keys stay a deliberate desktop override, and GORE keeps its drag-walk — there the assist recovers depth only and holding your own artillery lane is the point. **THE AIM IS DRAWN WHILE THE THUMB IS STILL MOVING.** Oliver asked to *"visualize the strength of my flick at flick-time"*, and he was asking for the half the placement ring cannot give: the ring is a RECEIPT, it arrives with the ball already gone. `G.swipe` (set by input.js during the drag, cleared on release) marks where the ball is being ASKED to land. **It is a cloud, not a diagram.** The first cut was a ring on a ray from the player's feet and it was rejected on sight (*"pretty bare bones... the line from the player to the indicator must go away, it is distracting"*): a line drawn across the court is a HUD element laid over the game. What is there now is a soft stain on the ground in the same procedural register as the dust and the blood — it GROWS with the length of the swipe and it WARMS as that ask approaches the width no timing can keep in. Three things were learned making it read, all of them the hard way: * **metres, not pixels** — sized off `p.s` so perspective shrinks it with distance. A pixel radius looked fine at the baseline and was an 8-px smudge at the far corner, which is where it lives. * **a stain, not a glow** — `globalCompositeOperation = 'lighter'` turned the red end YELLOW over green grass, and the hue is the entire message. * **the ramp is pale lime -> gold -> red, not green -> red** — green on green is invisible, so the obvious ramp showed nothing across the whole safe half of its range. The cool end carries its contrast in LIGHTNESS, which is what makes it read on grass, clay and slab alike. The threshold is not a flip: the colour is already gold before you reach it, so "too hard" is something you watch coming. **It is OFF as of its first playtest** (*"hide the indicator for now"*) — `TUNE.aimCloud` gates the whole block and the tuning panel can flip it back live. Kept rather than deleted: it is the only thing that shows the gesture's strength before the ball leaves, and the lesson it teaches has no other teacher. Shown from `TUNE.flickMin` for as long as the drag lives — since the crossing-fired flick (below) the vector stays live until contact, so there is no release deadline for it to lie past. Rally modes only (`rallyFeel`); gore's flick free-aims at a body, not at a lane. The placement ring turns `UI.bad` when the shot really is out, so the two rings together ARE the lesson: what you asked for, what your timing gave you, and the difference between them. **...and the flick fires the moment it IS a flick** (2026-08-20, Oliver: *"trying to move the approaching ball with a flick to the left is almost impossible ... standing closer to the grundlinie and more right from the center eases this restriction"*). The aim math was symmetric to the centimetre — the defect was LATENCY. The swing waited for the RELEASE, so the whole gesture ran as dead time on top of the 60 ms windup, and ~120 ms of lateness drops contact quality 1.0 -> 0.08, where `flickReach` halves the width and `aimError` drowns what is left. A LEFT swipe is the slowest direction a right thumb has (~250-350 ms vs ~150 for right), the service line leaves only ~700-870 ms of budget against even the slowest rung (~1.1-1.3 s at the baseline — measured), and any swipe over `FLICK_MS` (300 ms) was silently DEAD: no swing, no feedback, ball passes. Left + service line was exactly the slowest gesture meeting the shortest budget. Two changes, rally modes only (`rallyFeel`), input.js + one line in `trySwing`: **the swing fires when the drag crosses `flickMin`** (in `G.phase === 'rally'`, once per gesture) rather than at release, and **the aim stays LIVE** — `trySwing` reads `G.swipe` at contact, so the ball takes the vector under the thumb NOW; the released `flickAim` only carries it when the ball arrives after the finger lifted. The FLICK_MS deadline is gone with it where the crossing can fire — it survives only where it cannot (gore's free-aim flick, the serve-phase strike), where a late release must stay a rescue drag. The serve gesture, the duel and gore are untouched; keys unchanged. Verified with real touch events: held-swipe contact aims exactly `swayOf`, a 360 ms swipe (previously dead) swings and aims, and a release-before-arrival still aims through `takeFlickAim`. **...and an aimed shot keeps its DIRECTION** (2026-08-20, same report, second round — the crossing fire alone "did not feel much different", and his sharpened observation was that the flick only seemed to work when the opponent already stood where he aimed). Measured with the opponent teleported at contact: the aim never tracked the body — no magnet exists — but at rushed quality the SYMMETRIC scatter (±aimError·(1-q)) outweighed the quality-shrunk aim, so a mistimed left flick landed anywhere in a ~4 m blob around "slightly left": usually centre, sometimes right, and the eye anchors that on wherever the opponent stands. The opponent's position was a proxy for preparation time (a body parked wide = its return flew a long slow diagonal = time = quality). The dial: **`TUNE.aimTrust` — an OUTWARD scatter roll is untouched** (a committed mistimed swipe still sails at the exact tuned rate; `flickReach` stays the only out dial) **but an inward roll keeps only `aimTrust` (0.3) of itself**, so bad timing costs width, never the direction that was asked for. Player lane shots only: the opponent's symmetric scatter is its width mechanic, and gore's free-aim never used lanes. Verified in real rallies at worst-case q≈0.08: 14/14 rushed left flicks landed left (−0.8..−3.5), 9/9 right flicks right — previously the same condition was a coin toss. The suite pins the direction guarantee and the knob relationship (`aimTrust·aimError` must stay under a committed swipe's worst-case aim). Whether placement should also BEAT an unwounded body more often (`aiRead` on wide balls — "dial 2") is open, awaiting the phone verdict on this one. **...and the flick aims RELATIVE TO THE PLAYER** (2026-08-20, third round, and this was the core all along: *"when the opponent and the player are both standing in the left service box it is virtually impossible to flick the ball into the left out. no matter how hard/good I flick"*). The lane was ABSOLUTE — a full flick asked for `flickWidth` off the court's CENTRE — so the gesture saturated near a sideline: from x −2 a perfect left flick moved the ball under 2 m and could never pass the line (by design, 3.95 vs 4.115 — and the better the timing, the firmer the cap, because scatter was the only thing that could cross). The felt "left is impossible / right works" was never left-vs-right: it was toward-the-near-sideline vs across-the-court. **`TUNE.aimAnchor`**: the aim's anchor slides from the court centre to the player's own x as the swipe commits (`anchor = ch.x · |sway| · aimAnchor`), so a flat tap is exactly what it always was and a full flick is a full ±flickWidth sweep FROM WHERE YOU STAND — including past the line. **Out-by-aim exists now and is a choice** (his explicit direction: *"not about the possibility of the ball going out but rather the aiming itself"*); the placement ring already turns red for it. 0 restores the old absolute lane, blendable, panel-tunable for live A/B. Player rally lanes only — the opponent's lanes and the pOut invariants are stated from centre and unchanged there. Verified in his exact scene (both bodies in their left boxes): full left flick lands −5 (the alley), half swipe −3.07 (hunting the line, in), full right +1.93 (a full sweep measured from him). **...VETOED the same day and replaced by the wrong-side guard** (2026-08-20: *"no that's way too difficult now - I am dying constantly of out. revert it."*). The relative lane is gone (`aimAnchor` deleted; git has it if ever wanted). What survives is the answer to his follow-up — *"the ball sometimes actually flies a little to the right, although I flicked left. how can this be?"* — which is the absolute lane's blind spot stated the other way round: a mistimed left flick's target sits only slightly left of CENTRE, and that is RIGHT of a player standing in the left box, so the ball visibly flew rightward out of a left flick. **The aimed target is now clamped to the player's own x on the flicked side** (bounded at ±`flickWidth`, so a body parked in the alley clamps to the corner and can never conjure an out): from the left box a left flick goes at least down your own line, from centre it can no longer cross centre, a perfect corner flick and the crosscourt sweep are untouched, and the outward scatter tail — the whole tuned out-risk — is unchanged. Verified in his scene: ten rushed weak left flicks landed −2.0..−2.7 (previously targets −0.7..−1.3 — a rightward flight); the clean full flick still lands −3.92. **The umpire and the boy.** `aside()` refused during any interlude and the match runs on a permanent one, so he was silently muted for the entire mode — the one mode that is literally a tennis match he is sitting over (`G.interlude && !G.match` now, the same shape as `sendBoy`). Six dry lines: two on taking a limb, two on your blood, one when it is nearly gone, one on felling the thing, all behind the shared `asideEvery` cooldown. **The mop boy comes out between games**, to the FAR court (`sendBoy(0, 4.5, true)` from `matchGameWon`) — that is where one body has been bleeding through four wound stages, and it keeps him out of the near court where you are about to serve. Between games only; never mid-point. **Opponent serve games** were almost entirely switched off rather than missing: `setupPoint`'s tennis branch already placed a non-player server and armed `G.aiServeT`, and `update()` already tossed and struck for O — both gated on `G.mode === 'tennis'`. What was genuinely new: **service alternates at each game boundary** (the match calls `advancePoint` directly and the flip lives in tennis's `pointTo`, which the match bypasses — the same shape as the `G.serveNum` trap below), the zombie takes O's mark when it serves so the picture and the ballistics agree, and the serve POSES are named onto it (`bounce` → `toss` → `serve`). *Known simplification:* the interlude's line-call branch has no serve box and no second serve, so a missed opponent serve is simply the player's point. Add faults when it matters. **CLASSIC TENNIS is a real mode, not the opener with the turn switched off.** Oliver: "if the core tennis is good everything else is good." Six games with a two-game lead is a set, `TUNE.classicSets` is the match, no tiebreak (marked `ponytail:` — add it when a real match stalls at 6-6). The CPU stops rubber-banding there: `aiFlubLoss` exists to get a new player past the opener and would have him in pieces by the second set. It has its OWN ENDING with no corpse in it (`G.done = {won, t}`); all three endings share one render block, because three copies would drift the first time one moved. **THE RECORD SHAPE — read this before touching stats.js.** One character `m` on the run record (`g`ore / `z`ombie tennis / `c`lassic; absent means gore, so no migration). The record's two headline slots carry each MODE's own two numbers: | mode | `g` | `s` | `dead` | |---|---|---|---| | gore | games survived | sets banked | died | | zombie tennis | **opponents felled** | games played | died | | classic | games played | sets won | **match LOST** | `g` is what everything ranks on, so each mode's real measure goes in that slot and there is no second best stored anywhere to drift. `read(period, runs, mode)` filters; the stats screen is one function with three sets of words. The world board stays the gore mode's — it ranks kills in a run, and a five-body match would post a "5 kills" row next to a 300-kill massacre. A tennis board would rank FELLED with points won as the tiebreak; that is a server change. **The sprites.** The match opponent has its OWN art (`z_r*` and the nine families beside it), drawn off the LIVING opponent's poses: ready stance, shuffle pair, forehand, at each wound stage, both tours, plus the serve and the fall. The frame is picked off `racketAI`'s own `pose`, so the picture cannot drift from the behaviour. `z_c*` (the turned CPU) is untouched and keeps its shamble — in the gore mode shambling is the right thing for it to do. ### Traps this mode has already sprung — do not re-learn these - **`autoAssist` is 0 on `pointer: fine`.** The desktop pane has no auto-run. The match floors it with `matchAssist`, and `assistK()` is shared by the mover AND `intercept` — computing reachability with the raw knob made the planner answer "run home" forever. - **The contact chain is three separate things**: LEGS (`matchAssist`) → DESTINATION (`assistK` in `intercept`, plus the `anyHit` short-ball candidate that chases dying rollers) → REACH (`matchReach`). Each was a separate defect found in a separate recorded session. - **`ball.lastHit` must be stamped by `tossBall`.** A stale `'O'` on a toss from behind the baseline read as the opponent's passing shot: a point and blood PER TAP. Fixed, but the shape recurs — anything that revives the ball must own it. - **`G.serveNum` is only reset by tennis's `pointTo`**, which the match bypasses. `matchReady()` resets it; a new mode with its own point boundary must too. - **A mode gate written as `G.mode === 'tennis'` excludes the match** (it is `'splatter'` under a permanent interlude) and INCLUDES gore's prologue. Reach for `G.match` / `rallyFeel()` instead. - **`stats.banked` is only cleared by a KILL**, and classic tennis has none — so the second and every later classic match of a session was silently swallowed by the double-bank guard. `beginRun()` is the reset now. Any future mode with no kills in it needs that path. ### Do not - Re-add regicide to the match (killing the body with a smash) — removed deliberately; it made every hard serve a free unaimed kill and smeared the score-is-the-body relationship. - Touch the gore mode's contact/recovery rules while tuning the match. `matchReach` and the centre-recovery are match-only ON PURPOSE; gore splatter's tight contact and depth-only recovery protect the sidestep. - Put a body-shot thrower in the match. `shots[]` and the PARRY are built and unused here, and it looked like the cheapest depth in the mode — **Oliver scratched it, 2026-08-17.** Do not revive it without him asking. - Turn `drillOneShot` on as a default — it skips every `ZWOUND` stage, so the roster's best art never renders. - Revive the TRIAGE DRILL without a new idea. It still works exactly as designed and it failed the only test that matters: *"i think the normal player does not grasp this."* Four designs died learning that **an invented rule needs teaching; the rules a scene already implies do not.** That is why the match is tennis. **Expansion ideas that survived discussion** (also in LATER): the tournament frame (QUALIFYING → ROUND OF 16 → THE FINAL vs THE CHAMPION), the Hawk-Eye challenge as an earned continue, sudden-death deuce past a given opponent, doubles, and multiplayer. ## Session log: a lane is free when its body is gone, not when it is far away (2026-08-16) A seed-sensitive red suite (`SEED=1234 node test.js` failed on the drill's "spaced *after the walk in*" assertion) turned out to be the assertion telling the truth: **two drill bodies genuinely walked through each other.** The spawner measured its best-of-9 lane spacing only against bodies with `z < drillNearZ` (9 m) — sound for a court where bodies wander, wrong for this one, where a lane is HELD for life and speed is stamped at spawn. A body 12 m down still owns its lane; handing that lane to a faster arrival does not create a spacing problem at spawn, it books an overtake for ten seconds later, mid-court, as two sprites occupying the same metre. The fix is the filter: spacing is measured against every live drill body, `drillNearZ` is deleted (it had no other reader). No retry loop, so the stream still cannot stall. Measured, 60 seeds × 150 s of drill each: before, **7 seeds produced overlaps, narrowest clearance 0.00 m**; after, **zero overlap frames, narrowest 0.46 m** (the test asks for > 0.3). Peak on court is still the cap of 7, so the fan is wider, not thinner. Suite green on 20260811 / 1234 / 777 / 99 / 424242 / 8, and on 299 of seeds 1..300 — the one straggler is unrelated and still open (see below). **The one straggler the same fuzz found, and the arithmetic to copy for any rate gate here.** `SEED=80` failed *"opponent one returns a slid serve: 5/10"*. The receiver is innocent: measured over 200 trials a seed across eight seeds, the real return rate is **0.85..0.92**. The gate was simply too small a sample — asking 6 of 10 at p ≈ 0.88 fails with probability ~0.004, one seed in 230, and 300 seeds duly produced one. **n is now 40 at the same 60% bar**, which puts the threshold five standard deviations below the true rate while still failing instantly if the receiver regresses toward the 0-in-24 the test was born from. It costs nothing — a returned serve breaks the frame loop early, so the whole suite still runs in about a tenth of a second. Green on seeds 1..400. The general lesson: **a pass/fail gate on a random rate needs its sample size derived from the rate, not guessed.** If the true rate is p and the bar is b, n has to put b several standard deviations (`sqrt(p(1-p)/n)`) below p, or the gate is a slow coin flip that trains you to re-run the suite. ## Session log: THE NETPLAY LOBBY — a link to send, a code to type, a name to be (2026-08-15) Oliver: *"multiplayer should be easier to setup — a share with link next to the code; also entering the code is done in some plain js confirm dialog thingy, this could be embedded more nicely; somewhere when hosting or also when joining the player should name themselves."* All three are one screen (`#vs`, `index.html` + THE LOBBY in `main.js`); the protocol underneath is untouched. - **THE LOBBY IS A MENU SCREEN, and that is the whole structural change.** Hosting used to call `start('classic')` first and show the room code in a CANVAS modal over an empty court — which cannot carry a share button, cannot be typed into, and turned a mistyped code into a CONNECTION LOST card over a match that never began. The socket is opened from the menu now and the game stays parked behind the overlay until the room actually fills. **`netHooks.paired` is what takes the court**, called at the top of `netStart` — one line before everything that writes to it, so the kit/tour selection `start()` performs is already done when the `look` message reads it. Two more hooks join `intoSplatter`: `room` (the code landed) and `lost` (returns true when the lobby said so itself, which is what suppresses the card). Headless leaves them null. - **SEND THE CODE — and no link (settled same day).** `navigator.share` is the phone's own sheet — the friend is in WhatsApp, not in a room reading letters aloud — with `clipboard.writeText` as the desktop fallback. It shipped as SHARE THE LINK for an hour and the link came straight back out: **a URL only opens the INSTALLED app on Android** (the manifest's scope covers the site, so its WebAPK claims these URLs), while **an iOS home-screen app can claim nothing**, so on an iPhone the invitation opened a second copy of the game in Safari — its own storage, none of the name they picked in the app. Four letters typed into the app they already have is the honest version until this is a real app with universal links; the `#net-ABCD` door still exists and is exactly what a deep link will carry the day there is one, nothing merely sends it now. That door prefills the lobby rather than auto-joining, so the joiner still sees the name they will wear. - **ONE NAME PER PLAYER.** The field is prefilled from `stats.savedName()` and written back through the new `stats.saveName` (which `submit` now shares), so the world board's name and the netplay name cannot become two things. It crosses on the EXISTING `look` message as `n` — no new message type, and an older build simply sends none. **The scoreboard names the person** (`render`, the names block) in both netplay modes: FRIEND until the look lands, never CPU. A stranger's string lands in an 8-character slot, so it is normalised at the RECEIVING end through the same `tidyName` the board and `server.py`'s `clean()` agree on. - **Failure is a typo now, not a card.** A short code never opens a socket; `gone` puts NO SUCH ROOM — CHECK THE CODE over the picker with the field still there. CANCEL and the header chevron both `endNetVs`, so a room you walked out of gives its socket back. - Verified through the real relay in two panes: host → share link → join → both take the court, host serves, the board reads YOU / GUEST, and the death-match room rebuilds both courts the same way (blood pips, the friend as the living opponent, no roster body). 96 tests. - Smaller: the JOIN button is the crimson plate stretched to the field's own height (`#vs .joinrow .btn` — a bare `.joinrow .btn` loses to `#vs .btn`'s margin, and a margin on a stretched flex item is exactly the 8px it was coming up short by); `.tin` is now the one typed-field look, worn by the name prompt and both lobby fields. ## Session log: gore opens in the gore, the theme comes back, three boards (2026-08-15, same day) Oliver's finetuning pass on the batch below. ### THE GORE MODE STARTS IN THE GORE *"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 — a reveal the first time, a toll booth every time after. `gameplay.startGore()` is the direct door: it lands on the same state the transition's last beat lands on (extracted as `enterSplatter`, so the two doors can never drift), sets `everTransitioned` so the cinematic cannot fire behind it, and spawns the turned opponent as your first body — he is the man you came to play and the mode's premise is that he is already one of them. **The transition is NOT deleted**: it is still what `forceZombies` / `T.forceZombies()` plays, and it is still the best thing in the game to look at. It just no longer stands between the menu and the mode. The mode poster stopped promising a match that turns, because there is no longer one. `main.intoSplatter` — the back room's "get me into splatter" hack — now calls `startGore()` too, which deletes its 900 hidden `update()` calls. That loop fast-forwarded the whole eight-second cinematic inside a single frame, firing every sound it makes at once. ### THE MENU THEME COMES BACK, AND THE COURT STAYS ON THE COURT *"after some games — or returning to the main menu via the close button — it does not play the background music anymore but you sometimes still hear the court background noise."* Two halves of one bug, and both are `loopBed`'s early return. **It no-ops once a file is playing**, so `menuMusic()` could only ever START the theme and never bring it back from the duck `start()` put it under — the menu was silent for the rest of the session from the first run onward, and quitting was the reliable way in because the death summary is the only path that ever called `menuDuck(false)`. Meanwhile nothing ducked the court's own two beds at all, so the ambience followed you onto the silent menu. `menuMusic()` is now the one call that means "we are in the menus": it starts the theme, un-ducks it, and ducks both court beds. `ambient()` and `zombieMusic()` un-duck themselves for the same reason — the SECOND run would otherwise have been played in the silence the menu left them in. **A BED MUST RESUME THE CONTEXT WHEN IT FINISHES LOADING, NOT ONLY WHEN IT IS ASKED FOR.** The second half of the report — *"sometimes, when initially entering the game and tapping somewhere, the background music does not start; I suspect the sound is not yet loaded"* — and he had it exactly right. The tap unlocks the context and starts a 768 KB fetch; the `src.start()` that follows happens seconds later inside a promise, with no gesture anywhere near it. iOS suspends an idle context in that gap, and **a source started into a suspended context is silent forever** — nothing on the splash calls `audio()` again, so the theme simply never arrived. `loopBed` now calls `audio()` a second time in its `.then`, which is allowed because the first tap already unlocked the context. That is why it was intermittent: it only bites when the decode outlasts the browser's patience, i.e. a phone on a cold cache. A `visibilitychange` resume covers the same trap from the other end (a phone that locks ON THE MENU came back silent; during play every pock resumes it for free, but a title screen makes no sound of its own). **...and each of those un-ducks ITS OWN BED ONLY.** The first cut had `ambient()` un-duck both court beds together, which put the splatter music back on in classic tennis for the rest of the session — Oliver caught it on the retest: *"after playing the splatter gore, the zombie background sound also keeps playing in the normal tennis game."* LEAVING the court is one event and ducks both beds; ARRIVING is not. The ambience belongs to every mode, the zombie bed belongs to the gore, and only `enterSplatter` may bring that one up. **The rule, for anything added here later: a bed's start function must also un-duck it, because starting it is a no-op the second time and ducking it is not.** `audio.bedTest` exposes the duck set to the suite (same idea as `netTest`), which is what makes this testable at all — the beds never load headless, but the INTENT is the thing that was wrong. ### THREE WORLD BOARDS *"the world rank is still about the splatter gore which is not the only thing — we need something similar for the other modes."* `stats.BOARDS` is the only place that knows what each board ranks, and the server does not know at all: it sorts `k` then `g` within a mode and nothing crosses. | board | ranks | second column | |---|---|---| | SPLATTER | kills in a run | games survived | | TENNIS (arcade + cup) | opponents felled | games | | CLASSIC | games taken off the CPU | games played | Classic needed a number worth ranking — every win is exactly two sets, so a board of 2s is a wall of ties. `G.classicWon` counts the games YOU took and rides in the record's `s` slot, which held sets until now and which **nothing has ever displayed** (the sets line on the death screen is gore's; `read()` has never looked at `s`). The name prompt is per-mode too, gated on the board's own number rather than on kills — the old `run.kills > 0` test silently barred classic (no kills) from ever reaching it. Server: rows carry `m`, `GET /api/scores?m=g|z|c` filters, POST keeps one row per name **per board** and trims `KEEP` **per board** so a busy mode cannot evict a quiet one. Name ownership stays GLOBAL — one person owns their name on all three, or a stranger could take it on classic while you hold it on gore. Rows written before the split carry no `m` and are gore rows, which is the same convention the client has used since the stats screen split, so there is no migration. ### THE GORE MODE GETS HARD AGAIN *"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. The splatter gore should be unbelievably hard towards the end."* He is right and the arithmetic said so. `barrelEvery 3 / barrelRamp 8` drove the gap to **one** at game 16 and left it there, so the entire back half of a run paid out a weapon for every game won — while every difficulty ramp in the file reached its ceiling around game 25 and stopped. A run got richer after game 25 and never more dangerous. - **Barrels: 5 / 14, and a new `barrelFloor` of 3.** The ramp survives (the late game does need more inventory than the early one), it is just slower and it STOPS. `gameplay.barrelGap()` is the curve, exported and pinned by a test, because this is exactly the kind of one-liner that quietly becomes an income again. - **`zSpeedMax` 2.9 → 3.6** — shamblers keep gaining speed to ~game 28 instead of 22. - **`nastyMax` 0.75 → 1.0** — by game 34 there are no ordinary shamblers left in the mix at all. - **Ball machines scale**: `machCap()` is 2 + one per `machMaxPer` games, capped at `machMaxCap`. 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. The body COUNT ceiling (`zMaxCap`) is untouched and stays untouched: it is a readability limit on a 270 px screen, not a difficulty dial. What escalates now is what the bodies ARE and how fast they arrive. **Feel-pending: these are dials, and the end of a long run is the one part of this game that cannot be judged from the numbers.** ## Session log: the roster doubles, the draw is made on screen, and netplay stops locking (2026-08-15) Oliver's seven-item batch. All of it is on STAGING and pending his phone verdict; items 3-5 in particular need two phones on cellular, which is the only place the bug they fix was ever seen. ### 1. SIX NEW OPPONENT FAMILIES, and the ladder goes 12 -> 18 rungs *"expand the roster for the zombie tennis, use the proven way of generating the roster with the refcheck... head coach, stringer, strawberry seller, chair umpire, spectator, roller man."* Rolled through `_famroll.py` -> `_zsheet.py` exactly as the WHICH-TOOL table says, keys `hc sg sb cu sp rm`, each anchored on the body it already has in the gore mode (`z_coach1`, `z_string1`, `z_berry1`, `z_chair1`, `z_s1`, `z_k1`) — the rung and the thing that hunts you stay one person. 186 new sprites, 6 new head icons, `_heads.py` SRC/BOX rows for all of them. Placed by HIERARCHY rather than stacked on the end (spectator 3, roller man 5, seller 9, stringer 10, chair umpire 12, head coach 14), so the ladder still reads as the club's staff from the bottom up, and **the champion took the step up Oliver asked for**: 1.45 speed / 1.42 pace / 1.36 width / 0.38 flub, against 1.35 / 1.30 / 1.30 / 0.50. `CUP_ROUNDS` bands widened to match, `LADDER` is exported so the ramp test and the draw screen read the table instead of a pinned 12. **TWO PIPELINE LESSONS, both paid for in rolls:** - **THE RACKET IS THE POSE, NOT A PROP — say it out loud.** All six of these characters carry something in their anchor frame (a ball basket, a coil of string, a tray, an umpire's chair, a folding chair, a court roller), so each description had to say the thing is gone — and the model read "he carries nothing in either hand" as covering the RACKET too. Two families came back playing tennis bare-handed and the strawberry seller returned every shot with his tray. There is now a permanent clause in `_famroll.prompt()` saying the racket belongs to image 1's pose and is never removed or replaced. Describe a prop's REMOVAL by naming the prop ("the tray image 2 shows is NOT part of him"), never by talking about what his hands hold. - **A prop at head height defeats `_heads.py`'s shoulder-widening rule every time.** All six needed a hand `BOX`; the automatic crops came back 88x18, 36x17 and 35x16 — a letterbox of scalp. If a new family's head icon is a thin strip, that is the diagnosis, and a `BOX` row is the fix. - The club pro finally has a head icon too (`z_rhead.png`, cut from `z_c1` — the turned opponent IS him), so `SPR` preloads all of ZFAMS and the draw screen's portrait column is consistent. - **`ENEMIES['L' + i]` was reading BOSS_ART for the NAME and OPPONENTS for the line** — the same answer only while the two lists were the same list, and they were decoupled on 2026-08-13. The body-count screen has been printing THE PHYSIO over the mascot's line ever since. It reads one list now, which is also what stops rungs 11-18 having no row at all. ### 2. THE DRAW — the tournament's bracket, settled in front of you *"when starting the tournament there should be a view of the tournament hierarchy but only the finale is settled... random browsing through the roster in the individual spots which begins fast, gets slower and then settles on a random character, so that every tournament is new."* A new menu stage (`#bracket`, `main.buildBracket`), not a court-owning mode: SERVE UP lands here when the picked mode is `cup`, four rows roll their own band of the ladder at a gap that grows with p², they land in order at 1.5 / 2.05 / 2.6 s, and ENTER THE DRAW is `disabled` until the last one stops. The final wears its settled look from the first frame — it was never in doubt. **THE SCREEN IS THE DRAW, NOT A TEASER.** `startMatch(cup, draw)` takes the settled rungs and `cupDraw` reads them off instead of rolling its own, so the face you watched land in ROUND ONE is the body across the net. A screen that dealt a different tournament from the one it just showed you would teach the player that the whole ceremony is a lie. `cupDraw` still validates each rung against its round's band and rolls honestly if it does not fit, so a stale or hand-passed draw cannot deal THE CHAMPION in round one. `#cup` (the back door) passes no draw and behaves as before. ### 3-5. NETPLAY: the deathmatch lock, the court, and the ball that froze **The lock — "sometimes both players stand on the same side and nobody can serve" (cellular).** ROOT CAUSE FOUND AND IT IS ONE LINE: the mid-flight early-decide in `update()` was missing the `!G.net` gate its bounce-1 sibling in `onBounce` has carried since the netplay session. It exists to predict THE CPU'S LEGS; against a human on another phone it made the STRIKER a judge of its own outbound flight (`resolveInterlude` sends the verdict), so on a slow link both phones scored the same rally. The two scoreboards then sat a point apart forever — and that is not cosmetic, because `setupPoint` reads `score.pts` for the deuce/ad side and `score.server` for who tosses. One point of drift puts both players on the same half of their own court, each waiting for the other to serve, with no way out. **The one rule is that the receiving phone judges; this was the last place still breaking it.** The 2026-08-14 log claims the early-decide was gated — only the bounce-1 half was. Belt and braces, because that failure mode is a soft-lock rather than a wrong number: **the HOST's 10 Hz position stream now carries its scoreboard while it sits in `pretoss`, and the guest adopts it mirrored** (`netSync`). Gated to `pretoss` ONLY — nothing is mid-beat there and any verdict in flight has long landed — so a live rally is never reconciled underneath itself. One authority, no new message type. `score.games` is deliberately NOT synced (ponytail): it costs a wrong number on a board rather than a court nobody can serve on. **The court rides the room.** `?court=` at creation, stored beside `death` on the host's room in server.py, carried in the `start` message, applied by `netStart` -> `selectCourt`. Safe mid-run: the bounce pair is copied into TUNE and the backdrop cache keys on `court.id`. Two phones playing one rally on two surfaces disagreed about the bounce as well as the picture, and the guest never chose. **A called ball plays out.** *"as soon as the ball goes out the gameloop stops — it should just play until it came to a natural halt."* Every scoring path used to switch the ball off where it stood, which on the STRIKER's phone is a whole court and a latency away from the call. `ball.scored` is the new flag: the flight is decided, physics keeps running, and the ball dies where a real one would — by rolling to a stop under the existing `|vy| < 0.8` dead-ball rule. `endFlight()` in gameplay.js is the one place that decides which it is (netplay rolls, everything else stops dead, so no single-player feel changed), and `onBounce` returns on `ball.scored` at the top, which covers every call site in one line. The three consequences further down the physics block (the dead-roll branch, the baseline crossing, the retire) carry the same guard. `setupPoint` clears it. ### 6. THE NIGHT COURT A `COURTS` row (`gloom`, THE NIGHT COURT) plus one generated menu tile. It needed no new palette machinery — its TENNIS palette is simply darker than every other court's SPLATTER one, and `paintBackdrop` already paints every line in `UI.cream`, so the lines read as chalk under floodlights with no new knob. It borrows the hard court's stands and boards rather than growing two more sheets. **The one thing it did need is `dim`**: a fraction the STAND is taken down by before the rot even starts (`render.standDim`, 0.62 here and absent everywhere else). Without it a floodlit blue crowd sat above a near-black court and the picture stopped being a night. `standDim` is ONE function because the number is applied twice — to the cached sheet and to the live clap columns drawn over it — and two copies of it is a stand whose clapping columns pop brighter than the rest of it, the same class of bug as the two crowd frames drifting apart. ### 7. CAMERA FLASHES IN THE STAND `render.tickFlashes`, an OVERLAY like the clap ripple and for the same reason: the backdrop is one cached blit and anything animated inside it repaints the whole scene every frame. Clocked off `G.idleT` (the function has no dt, and idleT is the clock that never stops). A flash is a hot core plus a cold bloom, `TUNE.flashLife` 0.16 s — deliberately short, because a flash you can look AT is a lamp and one you only catch is a photograph being taken. The first cut drew a 6 px disc and read as a floodlight; it is 1-2 px now. `TUNE.flashEvery` (5 s, jittered 0.35x..1.95x) is the one knob, halved again while `crowd.cheer` runs — which is the "slightly increase during applause". ## Session log: MULTIPLAYER — the live 1v1 socket (2026-08-14). PHONE VERDICT PENDING Oliver: *"alright multiplayer"* — and, offered the recorded ghost-match step one, he picked the LIVE SOCKET instead, plus *"a new button with generated art in the main menu — the possibility to start or join a game"*. Built to the architecture LATER item 7 recorded; verified end to end in two browser panes through the real relay (pair → serve → mirrored flight → verdict → both scoreboards agree on the same point from opposite perspectives). 73 tests. - **It is CLASSIC TENNIS between two phones** — `G.classic` with the O opponent driven by the other phone instead of the AI. The mode poster is **VS A FRIEND** (`mode_vs.png`, generated in the zombie poster's night-court style); its second step is two chips, HOST — GET A ROOM CODE / JOIN WITH A CODE, and either chip IS the start — a room needs a socket, not a setup screen. `#net` hosts and `#net-ABCD` joins from a URL. The room code lands in a modal over the frozen court (an empty court waiting for a stranger is the calmest court there is). - **The relay is ~110 lines of stdlib in server.py** (RFC6455 handshake + framing, `/ws`). It holds NO game state: mints a 4-letter code (no I/L/O/U/0/1 — codes are read aloud), pairs exactly two sockets, forwards text frames verbatim. The host serves first on both scoreboards. 75 s socket timeout against vanished radios; the client heartbeats every 20 s. - **The sync unit is THE SHOT** (`src/net.js`): each phone simulates its own court with itself as P; a strike sends ~9 numbers and `mirror()` (x → -x, z → COURT.L - z) rotates them half a turn. The 10 Hz `pos` stream drives the O sprite and carries no authority. - **THE ONE RULE: the phone the ball is flying toward is the only judge of that flight** — line call, service box, double bounce, its own swing. The striker's outbound ball is picture (gameplay's tennis bounce block returns early on `G.net && lastHit === 'P'`) and the verdict arrives as a message: `shot` (returned), `fault` (re-serve), `pt` (point, winner relative to sender). This is what stops two sims a latency apart double-scoring a rally — the striker's copy would call a double bounce before the defender's swing had happened. An `applying` latch stops a received verdict echoing back out. Six tests pin the rule from both seats. - **startNetVs/endNetVs live in beats.js** under the ownership contract, and `G.net` joined every other start*'s refusal list. Serving is barred while unpaired or dead (`keyPressed`), O's auto serve and rally AI are gated off, `restartRun` closes the socket. - **Honest limits, all deliberate (ponytail)**: NO reconnect-and-resume — a dropped radio is a CONNECTION LOST card and a reload (the recorded plan calls reconnection "the bulk of the real work"; a prototype must not start there). ~~prompt() is the join UI. The scoreboard still says CPU.~~ *(both gone — see THE NETPLAY LOBBY above.)* A return snaps the ball to the mirrored strike (~latency of drift eaten in one hop; the flight swallows it). Runs bank as classic runs. The wound-decay hook (losing IS zombification) is the recorded next step once the feel survives a two-phone playtest. - **Test on staging with two phones** — the relay deploys with the ordinary push, nothing to provision. Coolify's proxy passes WebSocket upgrades as-is. **SAME DAY: THE HOST PICKS THE MODE, and DEATH MATCH is the second pick.** Oliver: *"the one creating the room could decide whether it is classic tennis or death match.. in deathmatch the wounded opponent would show increased dynamic bleeding before exploding."* The VS second step is three chips now (HOST — CLASSIC TENNIS / HOST — DEATH MATCH / JOIN WITH A CODE); the host's pick rides the room (`?death=1` at creation, carried in the relay's start message), so the joiner never answers a question. On pairing BOTH phones rebuild the court into the match machinery — the same verdicts drive `resolveInterlude`/`matchPoint` instead of `pointTo`, so blood, the mop beat and the scoring come along unmodified. **THE OPPONENT IS THE FRIEND, NOT A ROSTER BODY** (his retest: *"the opponent reads as zombie — it should be the normal opponent"*, and no SHAMBLER teaser card): nothing spawns, the living O carries the whole picture, and since the human sprite has no wound skins, **the score reads on him as BLOOD** — `bleedO()` in zombies.js opens an anchor per point taken off him this game and thickens the flow, love → game point, thin drops throughout (the settled rule: more emissions, never heavier drops). The game-winning point BURSTS him where he stands (`explode` — the human has no fall art, and procedural gore is the sanctioned fake), the mop beat runs over the gap, and IT RISES AGAIN returns him with the ramp reset. Their won points draw your blood; bleeding out sends `dead` — the other phone banks a NOT-dead match run and holds the win screen. Wired details: `d.opp` frozen (no roster climb — same friend every game), racketAI / the early-decide / the empty-court-regicide check all gated `!G.net`, the heal beat is a CARD in netplay (the modal froze this world while the other phone's kept moving), and `gushProne()` gives the SOLO deathmatch's kill fall the same bleed-out from prone-height anchors (bleed() stays suppressed there — standing anchors are the "blood fountains midair" bug). #netd hosts a death room from a URL. 79 tests; the blood-ramp/burst/return loop verified across panes. ## Session log: the far court, without spending the lens (2026-08-13) Oliver: the far-side opponent is barely visible in portrait, and the lens (camZ/camF, already at its measured ceiling — see fitViewport) and world height were the only knobs he knew. Two new ones. VERDICT (same day): the scale curve is settled-in (*"really good — helped a ton"*), the lean was vetoed (*"the camera movement is distracting"*) and its knobs sit at 0 — see the settled list: - **`TUNE.farK` — bodies draw off a flattened scale curve, the court projects true.** The 16-bit tennis trick: `s' = s·(sNear/s)^farK`, anchored at the player's deepest stance (he is untouched), clamped so nothing nearer ever shrinks. At 0.25 the far-baseline opponent draws ~20% bigger (31 → 38 logical px) and the court gives up nothing. Applied in render.js `farB(z)` to every body AND to every anchor that hangs off a body height (flames, boss HP, muzzle glow, drill chevron, umpire) — boosting only the sprite left the HP bar sitting on the boss's hat. Known residual: the ball keeps the true projection, so at the far baseline it meets a boosted racket ~5px off. 0 restores pure projection. - **`TUNE.farCamY`/`farCamZ` — a broadcast lean while the ball is on the far half** (tennis + match only; the gore modes would pump). Eased like aimCam, and pivoted about the player's deepest stance via `fOff` in render.js — without that pivot 0.35m of camY slides his feet 14px toward the rack, and the apron math promised that room. Measured in-pane: far court rises ~14px and grows ~1.3%, player feet drift ≤2.3px and return exactly. The backdrop cache repaints while the camera eases (~0.35ms/frame desktop, settled key at both ends) — if the phone stutters in rallies, zero these two before touching anything else. ## Session log: THE TOURNAMENT (2026-08-13, same day — Oliver picked it over the endless ladder) The fourth mode tile: four rounds up the dead hierarchy (`config.CUP_ROUNDS` — each round DRAWS a rung from its band, the final is always THE CHAMPION), riding the whole match machinery and differing in exactly three places: who walks on (`cupDraw` puts the drawn rung in `d.opp`, so every ladder dial downstream scales untouched), how deep the body's pool is (`matchOpponent` multiplies by the round's `need` and `matchPoint` divides the same way, so wound stages SPREAD across the round — the body enters game two missing what game one cost it), and where a won game goes (`cupGameWon`: mid-round the same body plays on; round won is the kill + mop beat + next draw; past the final, `cupWon` banks a NOT-dead match run and holds CHAMPION OF THE DEAD CLUB on the classic win's summary shape). Blood carries; the trainer's heal-per-game is the distance. Round one is a single game so a run opens fast; deathmatch stays beside it as the arcade mode. Also this session: the fall ANTICIPATES the pass (a winner past the net with a closed-form unreachable landing resolves mid-flight — plus the soft-lock the first version shipped: the decided-latch must die with its rally, see the beats/gameplay comments); the fall table lost its rise row and tightened its drop; the ghost trail is PACE-qualified so both tennis modes streak (`trailSpeed`); and opponent sprites draw brighter via a load-time baked copy (`render.brighten`, `TUNE.oppBright`) — no PNG touched. ## Session log: the serve gets stakes (2026-08-13, same day) Oliver: *"the golden mark is just very easy to hit and hitting below it does not necessarily make a worse serve... the flicking does not work here — also I see no option to serve harder."* Three mechanical causes found, all fixed; PHONE VERDICT PENDING: 1. **The perfect window was ~0.3 s of hang time.** The 0.12 m snap around the apex, at toss gravity, held PERFECT for most of the ball's float. Now `TUNE.apexSnap` 0.05 / `apexFall` 0.55, and the gold band on the apex guide reads the same knob, so the picture tightened with it. 2. **The pace ceiling was the TARGET DEPTH, not the timing.** Measured with a replica of serveArc's loft loop: at the old fixed mid-box depth, every serve above q≈0.7 was cut to the same ~36 m/s — quality could not show. Player serve depth now rides quality (flush aims 0.3 m inside the service line, the only depth where flat pace survives). Ladder: flush 49 / flush+hard 52 / mistimed 36 / bad 30 m/s. Opponent depth untouched. 3. **The strike flick was splatter-only.** In tennis and the duel the strike gesture now aims: horizontal component sways the target toward T or sideline (`serveAimX`), upward component is pace (`serveHard`, with `serveHardSpin` — the extra dive is what buys the clearance; without it the loft loop cut the hard serve BELOW the plain one). Pace and sway are paid for in scatter (`serveHardErr`) against the real fault machinery — tap stays the safe second serve. Keyboard: arrows sway, up is the hard one. Verified in-pane with synthetic flicks: 49.4 / 52.5 / 51.0 swayed +1.1 m, all legal at flush timing. ## Session log: nothing arrives unannounced (2026-08-11, same day) Oliver's retest: *"the horde is not announced anymore"* and *"bosses are just there now"*. Both were true, and both were the same mistake — the announcement fired on the same frame as the thing it announced, so it was over before it registered. - **The horde gets its breather back.** `warnHorde()` (beats.js) raises THEY KEEP COMING and holds an ordinary 'over' beat for `TUNE.hordeWarnT` (2.4 s); `startHorde()` runs when it expires. The cheat (`forceZombies`) still starts one instantly — it is a cheat. The breather runs on **its own clock** (`G.hordeWarn`, ticked in update above the 'over' block), deliberately NOT on the 'over' beat it also sets: any beat that moves the phase underneath it would strand the latch forever, which is the `mgT` lock this file already has a section about. `startHorde()` **returns a bool** now, and if the court has been taken when the warning expires the caller re-arms `pendingHorde` — the set gets its horde later rather than losing it silently. - **The chapter boss walks on behind an OVERLAY**, not a band card: the boss's name in `UI.bad` over `ENTERED THE COURT`. It is a modal (a boss stage is guaranteed calm — `startBossFight` clears the court two lines earlier, so this is on the sanctioned list) with the new `m.hold` self-dismiss clock, `TUNE.bossIntroT` = 3 s. A card in the routine-set slot and the routine-set size is why the biggest fight in the game read as the boss simply being there. - **The flamer does not trigger a horde** (his "vague feeling"). Arming any `kind: 'burst'` tin puts you in the same CONTROL MODE a horde does — racket auto-fires, the finger steers you absolutely, no serving — so a flamer or gun genuinely feels like a horde starting. Nothing in the weapon paths touches `startHorde`; with the horde announced again the two are told apart by the announcement. - Three tests added: the wave may not land on the frame of its own announcement, a mode that steals the court during the warning re-arms the latch, and the boss overlay names the boss and hands the court back without a tap. **Same round, the burst weapons.** *"When firing a burst tin the weapon stops when it has destroyed all visible enemies. It should just fire on."* It did, and the cause had nothing to do with enemies: both the auto-fire and the steer were gated on `G.phase === 'rally'`, and every beat raised by clearing the court parks the phase in `'over'` (a point, a won game, the mop-up). So the moment the flamer killed the last body it went silent AND the player froze — worst possible timing, and it read as the weapon jamming. **A burst is its own control mode for its whole `len`, exactly like a horde: its own clock is the only thing that ends it.** The fire block in `updateZombies` has no phase guard at all now, and the movement block runs on `G.phase === 'rally' || G.mgT > 0`. The settled "the player only walks during rally" rule is untouched — nothing arms a burst except the player. Deliberately NOT fixed by forcing the phase back to `'rally'` during a burst: that would stop `overT` ticking, and a parked beat that never expires is how `pendingHorde` gets stranded (see the mgT lock). **And the burst countdown is the TILE now, not a bar.** It was 1.5 px along the tile's bottom edge — which is both the rounded frame and, on a phone, the very bottom of the screen. The armed slot's WELL drains instead: a gold wash across the whole tile, emptying leftward, clipped to the tile's own rounded path (`panel` leaves it as the current path) and drawn under the icon and labels. Measured lit vs empty on the live tile: rgb(88,125,68) against rgb(31,84,49). ## Session log: THE COACHING DRILL — the cognitive mode, as a prototype (2026-08-11, same day) Oliver and his team-mate decided the game needs **a different angle alongside the gore mode**, after he reported the late game going repetitive: *"something that requires some immediate strategy other than saving specific weapons for a boss I was killed by last time"*. Three modes planned — classic tennis, **splatter tennis** (this), splatter tennis GORE (what exists). What went in today is the middle one's feel-test, not the mode: **back room only** (`DRILL TEST`, or `#drill`), no menu entry, nothing wired to tennis scoring. **Why the late game is flat, for the record** (diagnosed before proposing anything, per the meta-lesson). The optimal move never changes: kill the nearest thing, now. Every interruption is on a fixed metronome (duel/3, machine/4, horde/6, boss/10, barrel/every game past 16), the director's dials all bottom out by ~game 25, nothing is scarce once the rack is full — and **the zombie side's score is unreachable by construction**: contact is a radius around the player and bodies home on his x, so anything that could cross the baseline eats him first. The crossing branch in `updateZombies` (and the `ump_unsportsmanlike` line with it) is very nearly dead code. Oliver caught that himself — the first design direction proposed this session was built on it and had to be thrown away. **The mode.** The umpire CALLS a colour sequence aloud while bibbed bodies jog on and take their marks; then they come for you and you put them down in the called order. Colours never called are DECOYS — bodies walking at you that you are required to ignore. Two faults = double fault = the round is theirs. Settled in discussion: **audio-first, and shown as a pattern too.** - **`voiceSeq` in audio.js** is what makes the call possible at all. `voice()` DROPS a line that would start while the umpire is still talking (one man, one microphone) — correct for unrelated triggers, fatal for a list. It decodes every clip FIRST, then queues them all at accumulated offsets, so the anti-overlap rule is satisfied by construction rather than fought. Five new recordings (`say -v Daniel -r 168` -> `afconvert`, the existing pipeline). - **Four colours, not six**, and every bib carries PIPS as well as a hue: ~8% of men cannot separate red from green, and a mode whose entire input is "which colour is that" owes them a second channel. The same pips appear on the HUD strip, so the two readouts teach each other. - **The bib is a DRAWN OVERLAY and it knowingly breaks the sprite rule** (`ponytail:` marked in `drawBib`). Six bib sheets x every walk frame is a week of art to answer a question a trapezoid answers today. If the mode survives its playtest, `drawBib` is the thing to DELETE and replace with a real bib sprite per colour, drawn like a wound skin. Shoulders wider than waist — the first cut flared the other way and every body was wearing a sandwich board. - **TWO functions pick a target, and snapping only one of them was the bug.** `flickTarget` (the slingshot) and `zombieTarget` (the plain tap) are separate paths; with only the first snapped, fifteen tap-serves in a row scored zero and the round just ran out. Both snap now. Anything that changes how a shot chooses a body has to touch both — same shape as the "three of five weapons silently no-oped on the racket zombie" bug. - **Contact costs the POINT, not the run** — a deliberate contract change, hooked in `killPlayer` (the single death path for both damage channels, which is why one hook covers every way in). Permadeath stays settled in the gore mode: a memory game where one lapse deletes a thirty-minute run punishes the exact skill it is trying to teach. - **The empty-court net** in `updateDrill`: a live round with nothing bibbed on court means something outside the drill emptied it (seen once while wiring up, not reproducible afterwards). Unrepaired that is a dead mode — no targets, no clock, nothing to tap. It redeals and logs `INVARIANT`, the same contract `watchdog` uses. `startDrill` also DISARMS the ordinary schedule (`pendingHorde`, `hordeWarn`, `bossDue`, `duelDue`): taking the court is not enough if someone else's arming latch is still loaded behind you. - **Eight new tests** (24 total) pin the ownership contract both ways, the call beat, the clean win, the double fault, the un-winnable-round guard and the softer death. **SECOND PASS, same day — Oliver played it: "the auto aim just mean no skills involved at all. this should just go away.. there is no penalty at the moment.. make some variations of the position on the court and the color".** All three landed, and the first two are the same finding: with the shot locked to the called body the mode was unmissable, so **the fault could never fire** — there was no penalty because there was no way to earn one. - **Both snaps deleted.** You aim. What replaced them is one line: `aimTie` is **0 during a drill**. That is not auto-aim, it is removing the *guess* — the ordinary magnet lets a nearer body inside the same cone steal a shot aimed past it, which is right for a crowd you are mowing down and exactly wrong where hitting the wrong body IS the failure. An unaimed tap now targets nothing: `splatServeTarget` returns null and `hitServe` falls back to the service box, so the ball lands on grass. Verified end to end in the browser: aiming at a decoy kills the decoy, raises FAULT, and sets the rush; a second wrong body prints DOUBLE FAULT and the round is theirs. - **The palette is SIX** (purple and white added, two more `say -v Daniel` recordings) **and the court takes three to five of them** (`drillBodiesMin`/`drillBodies`). Four bibs out of a four-colour palette meant every round dealt every colour and only the order changed — three quarters of the round was known before the umpire opened his mouth. Now which colours turn up, how many, and how many decoys are all live questions. - **`PLACEMENTS` in drill.js — five shapes, weighted, mirrored left/right per round**: `fan`, `cluster`, `column` (two or three SHARING A LANE at different depths — the shape that makes feet matter, because the ball hits the first body it touches), `stagger`, `wing`. Lanes are shuffled against the bib order, so the called order is unrelated to both the left-right order and the depth order. Measured over 30 deals: all five shapes appear, counts 3/4/5 roughly even, all six colours rotate, and **the first called target is screened by another body in about 10% of rounds** — often enough to matter, rare enough not to be a tax. - **The pips are DICE FACES now** (`PIP_FACES`, shared by the bib and the HUD chip so they cannot drift). A row of five dots has to be counted; a die is recognised — and the redundant channel is pointless if reading it costs more time than reading the hue. **Known limit: the die reads on the HUD chip at any distance but degrades on the BODY as it recedes.** The bib colour carries; the face does not. If that turns out to matter, the answer is a bigger bib, not more pips. - Ten tests now (26 total), including a named regression for the auto-aim being gone and one that deals 40 rounds and asserts colours, counts and shapes all move. **THIRD PASS, same day — "the drill mode immediately goes into some horrible audio loop" (phone).** Never reproduced on desktop, and that is the tell: **three of the four defects behind it can only happen on a phone, and my whole verification had been stepping frames with rAF killed on a machine whose AudioContext was already running.** All four are in `voiceSeq`, which is new code, and every one of them is a rule `voice()` had followed for months and my function quietly broke: 1. **A SUSPENDED AudioContext HAS A FROZEN CLOCK.** On a phone the context starts suspended and `currentTime` does not advance until it resumes. Scheduling four clips at `currentTime + 0, +0.9, +1.7, +2.4` against a frozen clock puts every one of them in the PAST by the time the context actually starts — so they all fire on the same instant, on top of each other, through a 1.3 s reverb tail. Resume FIRST, and re-read the clock after the decode. 2. **A SECOND CALL LAYERED INSTEAD OF REPLACING.** `voice()` has always enforced one man, one microphone; `voiceSeq` SET that clock (`voiceUntil`) and never checked it. Two calls close together = two overlapping lists. It stops the outstanding one now. 3. **CANCELLING A LIST HAS TO GIVE BACK THE TIME IT BOOKED.** The first cut of fix 2 stopped the old sources but left `voiceUntil` standing, so the replacement queued behind a list that would never play — measured at **6.6 s of silence** before the call the player is waiting on, and every redeal made it worse. `seqUntilBefore` rewinds the booking. 4. **THE AMPLIFIER, in drill.js: the empty-court net redealt ON THE SPOT**, which makes the repair rate the FRAME RATE — and every deal calls the umpire. It goes through the ordinary between-rounds beat now, so the worst case is one redeal every `drillBeat` seconds: a mode that limps instead of a phone screaming at you. Pinned by a test that empties the court every frame for six seconds and asserts at most one call per beat. **And a measurement lesson worth more than the fix.** The first "it's clean" reading was taken by counting `fetch`es of `assets/audio/*` — which proved nothing, because `VOICE[]` caches every clip and a repeat play never fetches again. The honest probe tags each decoded buffer with the file it came from (patch `decodeAudioData`, pair it with the pending fetch URL) and counts `AudioBufferSourceNode.start` — then "did two umpire lines overlap" is a real question with a real answer. Counting requests measures loading; counting `start()` measures sound. **FOURTH PASS — THE STREAM. Oliver played it again:** *"daniel announcing is maximum annoying"* · *"don't let the rush in position just let them coming from the back in steady speed.. try to space them not too close"* · *"when one combination is done just let the new one in.. in a continuous stream that increases in speed and difficulty"* · *"the rounds can stay, they should be continuous"*. **The ROUND survives as the unit; every SEAM around it is deleted.** No walk-on to a mark, no announcement beat, no between-rounds pause, no deal. Bodies walk in from the far baseline forever, and a round is a colour combination drawn from whoever is on court — finish one and the next is drawn in the same frame, off the same rolling population. The bodies you were ignoring a second ago are the ones being called now, which is a better decoy than the old fixed-batch decoy ever was. - **Daniel is gone from this mode.** At ~0.8 s a word he needed four seconds to read a list of four, and with rounds running continuously that is a man talking over the entire game. Each colour has a PITCH now (`DRILL_COLS.tone`) and a round announces itself as a four-note figure in half a second (`blip()` in audio.js — a plain triangle with a 12 ms attack, because a hard attack clicks). `TUNE.drillTones` 0 makes it silent. The `drill_*.m4a` recordings stay in assets: cheap to keep, and a slower variant of the mode could want them back. - **`PLACEMENTS` is deleted along with the marks.** Nothing rushes anywhere and nothing stops, so there are no shapes to place — the geometry now comes from lane choice at spawn plus the depth the stream naturally strings bodies out into. That is a net DELETION of the fanciest code in the file. - **Spacing is best-of-9 lane sampling** against every live drill body (it measured only the back half until 2026-08-16, see that session log), not a retry loop: a hard minimum would silently stall the stream at exactly the moment it is meant to be getting harder, which reads as the mode dying. - **A DRILL BODY HOLDS ITS LANE**, and this was the fix the screenshot forced. `updateZombies` homes bodies onto the player's x once they cross the net — a gore-mode behaviour — so they arrived nicely spread and then bunched into an unreadable knot right in front of you. Spawn spacing cannot survive that. Their lane IS the puzzle here (it decides what screens what), so drill bodies walk it straight in with nothing but the wobble. They still reach you; the lane runs past the baseline. - **One ramp, off TIME SURVIVED** (`TUNE.drillRampT` 210 s), driving walk speed, spawn gap and round length together. Time, not rounds won: a round-counted ramp cannot escalate while you stand still. Speed is stamped per body AT SPAWN, so the ramp shows up as new arrivals outpacing the ones already walking rather than as the whole court accelerating out from under you. - **The empty-court watchdog is gone too, and nothing replaced it.** With a stream there is no state to be stuck in: `if (!d.seq.length) newRound(d)` covers the start, a completion, a double fault and "everything named got killed by something else". One line replaced a repair path, a cooldown and a log entry. - Movement is `updateZombies`' ordinary forward step now (bodies carry a real `zb.v`), so the stride animates off distance covered for free — the round-based version drove them by hand because they had marks to stop at. - Tests rewritten around the stream (27): arrivals keep coming and are spaced *after the walk in*, lanes are held by identity, the ramp raises speed / arrival rate / round length, a finished round rolls into the next in the same frame, and being caught costs the point and the court but not the run. **Two test traps worth remembering**: a drill test that never shoots WILL be caught (~28 s to walk the court) and the sweep looks exactly like a stalled stream — use `TUNE.eatenR = 0`, the established soak trick; and never compare `zombies[i]` across frames, because bodies despawn at the baseline and the array shifts (that read as a 1.5 m lane drift that never happened). **FIFTH PASS — two cuts.** *"what are the dice dots for?"* -> *"drop them"*, and *"the dodge swipe should not reset to the middle when firing"*. - **The die-face pips are gone from the bib and the HUD chip.** They were colourblind redundancy — a channel so the mode did not rest on hue alone — but they were noise to everyone who can separate the colours, and they shrank to nothing on a body at distance, which is exactly where the channel had to work. **This is a known accessibility gap now, recorded rather than solved**: if the mode ships, the answer is bibs that differ by SHAPE or marking at sprite scale, not dots. A channel that fails at the far baseline is not a channel. - **The dodge fix is in gameplay.js, not in the drill**, because it was never a drill bug — see the new invariant above. It changes the gore mode too, and deliberately. **SIXTH PASS — THE MODE IS WINNABLE, AND THE DANGER MOVED.** Oliver: *"make the game that it is actually winable meaning that all required colors are visible on the far side before when a color combination is shown.. for now no colors that are not in the current combination but occasional zombies which don't have a color and are converging to the player trying to eat him"*. Two rules now define the file, and everything else falls out of them: 1. **Every colour on court is IN the current combination.** No coloured decoys at all. A colour you can see is a colour you will be asked for, and the strip can never name a body that is not there. 2. **A combination is only shown once its bodies are all on the far side** — free, because the BATCH IS BORN THERE. It is a property of the construction rather than a check that could be wrong: a batch spawns at the far baseline, its combination is called off it on the same frame, and the length of the court is the time you are given to work the list. **The pressure moved to the EATERS**, which is a better home for it: plain unbibbed shamblers on their own clock (`drillEaterEvery` -> `drillEaterMin`, capped at `drillEaterMax` on court). Deliberately NOT flagged `drill`, so they keep the gore mode's homing and converge on you while the bibbed batch walks its lanes. Killing one is never a fault (`drillKill` ignores a body with no bib) and letting one reach you is the ordinary caught. **The puzzle plays fair and the danger arrives on its own clock** — much better than a puzzle that cheats. The batch is the round: kill the last of it and the next walks in on the same frame with its combination already called. `drillBatch` 3 -> `drillBatchMax` 6 on the ramp, so the list grows AND the court fills. **One real bug, and the new test is what caught it: `killZombie` resolved the drill BEFORE splicing the body.** `drillKill` can draw a new combination on the spot (a double fault re-calls the survivors; an emptied batch brings in the next one) and it draws from whatever is standing in `zombies` — so the new combination could name the body that was in the middle of dying. That is a round asking for a colour which is not on court: precisely the un-winnable state this pass exists to make impossible. **Splice first, resolve second.** **SEVENTH PASS — the colour comes off the body, and the mode stops making noises.** Oliver: *"the colored zombies should also explode like they did before and make the normal splash sound on destruction.. no bleeps please.. can we make the color as a color circle the walk on? the sign they're holding is not nice. also remove the ball slicing in this mode.. this leads to unwanted hits"*. Four things, and one of them was a straight bug of mine. - **THE BUG: bibbed bodies were dying silently and cleanly.** My hook in `killZombie` did `drillKill(zb); return;` — and everything below that return is THE DEATH: the blood decal, the pile, the 36-particle burst, the splat and the headshot crack. The drill resolves after the splice and **without returning** now. Both halves are load-bearing and each was a bug in turn: splice first or a freshly drawn combination can name the corpse; don't return, or the body just vanishes. - **The colour is a DISC ON THE COURT that the body walks on**, drawn BEFORE the sprite. The bib was a flat quad pinned to a walking sprite — UI stuck onto a character rather than part of the world, and it covered the artwork. On the ground it sits in the court's own perspective, it is far larger in pixels than a chest patch at the same depth (measured 12.4 x 4.2 logical px at the spawn line, 24 px wide by the time it reaches you), and it hides none of the sprite. The risk was reading as another gore decal; the answer is the RING — gore is a soft flat blob, this has a hard dark rim and a bright inner line, so it reads as painted court marking. `TUNE.drillRingR` sizes it. **Weakest case is GREEN on grass** — legible thanks to the rim, but it is the one to watch. - **NO SLICING IN THE DRILL.** A cut ball leaves the corpse and strikes whatever it finds next; in a mode where hitting the wrong body IS the failure, that is the game choosing your target for you and choosing it badly. One guard on the `ball.chain` branch. This also retires the old open question about a ball completing two steps of a combination in one shot. - **THE MODE NOW HAS NO SOUND OF ITS OWN, deliberately.** Two attempts have been vetoed: the recorded umpire reading the list ("maximum annoying") and the arpeggio that replaced him ("no bleeps please"). `blip()` is deleted from audio.js, `drillTones` from TUNE, the correct-kill tick from `drillKill`. What is left is the game's own vocabulary — a kill bursts and splats like every other kill — and the strip carries the list. **Do not add a third noise here without a reason**: both attempts read as the mode talking over itself. **EIGHTH PASS — the court gets quieter.** *"remove the mob boy - make the normal zombies rarer and not so close to the color gang"*. - **`sendBoy` refuses during a drill**, at the function rather than at the call sites (same shape as its existing `G.interlude` guard, so every present and future caller inherits it). He is one more body crossing the lanes you are reading colours down, and unlike everything else on that court he is neither the puzzle nor the threat — pure noise. Measured: 0 boy-frames in 90 s. Note this means **gore no longer gets mopped in the drill**; the `gore` 40 / `piles` 30 caps are what keep it bounded, which they already did. - **The eaters are rarer**: `drillEaterEvery` 7 -> 12 s, `drillEaterMin` 2.2 -> 5, cap 4 -> 3. At the old rate they were a second crowd rather than an interruption, and a threat you break off to deal with only works if breaking off is occasional. Measured: 8 arrivals in 90 s where the old numbers gave ~13. - **And they enter WIDE of the colour gang** — best-of-9 lane sampling against the bibbed bodies, the same trick the batch spacing uses. Measured narrowest clearance at entry across 8 spawns: 0.91 m. They still converge on you afterwards, which is what they are for; this only decides where they come in, so they stop screening the bodies you are trying to read. **NINTH PASS — THE ROSTER IS THE ALPHABET, and it deletes a whole subsystem.** Oliver: *"we don't need colors at all! we already have different zombie types.. so the sequence is the zombies themselves"*. He is right, and it is better on every axis the colour version was losing on. The bib, then the ground disc, were both inventions bolted on to give a body an identity **it already had**. A brute does not look like a runner, the game is built on reading those silhouettes at speed, and the strip can show the body's own walk frame instead of a swatch — no legend, no mapping, no new art ever. It also **deletes the accessibility problem outright** instead of deferring it, because a silhouette is not a hue. `DRILL_COLS`, `drawColourRing`, `drawBib`, `PIP_FACES`, `blip()`, `drillRingR`, `drillTones` and the seven `drill_*.m4a` call recordings are all now gone or unused — the mode is materially smaller than it was three passes ago. TWO RULES keep a type unambiguous and both are load-bearing: 1. **the plain shambler is never in the alphabet** — 'a'/'b' is what the EATERS are, so "not part of the puzzle" is legible at a glance and needs no marking on anything; 2. **one of each type on court at a time**, guaranteed by the batch dealing distinct types and by eaters only ever being shamblers. "The brute" always names exactly one body. **The new failure mode this created, and it is a good example of the class.** Types move at different speeds, and drill bodies hold their lane — so a body in a lane you are not standing in walks the full court and off the back untouched. Left as the ordinary despawn that stranded the sequence asking for a body which **no longer existed**: precisely the un-winnable state the mode exists to prevent, reintroduced by a change that looked purely cosmetic. Two fixes, both needed: - **a batch body crossing your baseline counts as CAUGHT** ('GOT PAST YOU'), which is also just better — the drill is "don't let them through" and now the lanes mean something; - **the speed spread is narrowed to 1.35..0.70**, not the roster's own 3.2x..0.45x, so a batch stays coherent. The types still read as different because their real difference is **HP, untouched**: a runner is one shot and a roller is seven, so the called order still decides how early you must start on what. Verified: four minutes of simulation, 14400 frames, **zero frames** in which the sequence named a body that was not on court. Watch: **a roller in the middle of a sequence is seven aimed serves.** Oliver asked for the roster's robustness to be kept, so it is — but if a round ever feels like grinding rather than reading, a drill-only HP scale is the knob, not a change to the roster. **TENTH PASS — two switches, and the round-boundary bug.** - **`TUNE.drillOneShot` — tried ON for one playtest, now OFF.** *"one-shot is bad - way too easy - also we're losing all the nice gore animation sprites"*. The second half is the one that is easy to miss and worth remembering generally: **a 1 hp body never passes through a wound stage**, so every skin in `ZWOUND` (arm off, ribs out, still walking) is skipped and the roster's best art never renders at all. The HP is also what makes the called ORDER matter — a roller is seven serves, so being told to take it third means starting on it first. Kept as a switch, never a default. - **THE ROUND-BOUNDARY CARRY-OVER, and it was a real unfairness.** *"the last kill of a game can carry to the spawn of a new zombie of the next round"*. A ball keeps pace through a kill (`TUNE.zPierce`) and it travels UP-COURT — which is precisely where the next batch is born. So the shot that finished your round flew on into bodies that did not exist when you took it and faulted you on a round you had not been shown yet. **The shot that ends a round is spent** (`ball.active = false` in drillKill's completion branch, and in `drillCaught` for the same reason: that also refills the court up-court). Pierce WITHIN a batch is untouched — that is honest occlusion you could have read; this was not. - **`TUNE.drillEaterMax` 3 -> 0 (OFF)**: *"remove the fake zombies for now"*. The drill is the pure puzzle with no second threat at the moment. The mechanism is untouched — `spawnEater`, the wide entry away from the gang, the never-a-fault rule — so one number brings it back. The test suite asserts **whichever contract the knob currently describes** (at 0: nothing but the puzzle ever appears; above 0: they arrive, capped, always plain shamblers) rather than being deleted and rotting, and the never-a-fault rule is now tested with a hand-placed body so it holds either way. That is the shape to copy for any other switchable subsystem here. **ELEVENTH PASS — TRIAGE: the drill finally gets a skill.** Oliver's verdict on the sequence version was the honest one — *"it feels not interesting enough to keep me playing.. I am really looking for some mechanic to make this skill based"* — and the diagnosis was that it tested nothing: the strip was always visible (no memory), the batch was slow (no urgency), the aim was easy (no execution). A checklist with gore. Settled in discussion: options were hide-the-call (memory), triage (judgment), clock+streak (score); he picked **triage + streak**. **ONE RULE: always kill the body that would reach you first.** Nothing is called, nothing shown — the court is the puzzle. `arrival(zb) = (COURT.L - zb.z) / zb.v` down a HELD lane (that is why drill bodies hold lanes — it makes the projection honest), and the roster's speed spread means the answer is not the closest body: a runner at the far baseline beats a roller at the service line. This is court sense, the most tennis-flavoured skill there is, and it trains the exact target-priority read the gore mode rewards. The strip, the alphabet, the batch and the eaters are all deleted — shamblers are first-class stream members now, `pts` 20 up to a roller's 100. The three design points that make it a game rather than a quiz: - **WOUNDS ARE FREE.** Only the KILL is judged (drillKill sits behind the kill funnel, wound() never reaches it), so pre-softening a heavy while it is third in line is legal and eventually necessary. This is also why `drillOneShot` must stay off: 1 hp bodies delete the planning layer. - **THE SLACK** (`drillSlack` 0.9 s): two bodies arriving within it are both legal. Without it a near-tie made the rule a coin flip and a fault read as theft. Tightening it is the difficulty curve for good players. - **THE STREAK IS BOTH PUNISHMENT AND PAYOUT.** x1..x5 (`drillMultEvery` 4, `drillMultCap` 5), a misread resets it and shows OUT OF TURN, and nothing else happens — no sweep, no lost round; the queue keeps walking. Being CAUGHT is the real setback (sweep + ramp handback), score survives. Best is banked in localStorage (`drillBest`); the world board can carry it if the mode ships. **`TUNE.drillMark` (ON for the playtest): a chevron over the queue head.** It exists so Oliver can check the game's read against his own — and it answers the exact question the mode asks, so it MUST be off in anything shipped. If his read and the marker disagree often, tune `drillSlack` before touching `arrival`. **TWELFTH PASS — the marker stops lying, and the rule announces itself.** Oliver: *"the marker flips all the time even during a ball being midair.. i am not sure that a player would get the mechanic intuitively let alone without the marker"*. **The flip had a specific cause and it was a design bug, not jitter**: with lanes held and speeds constant, arrival DIFFERENCES never change while bodies walk — the queue only reorders on a kill or an arrival. So the marker was flipping because it showed ONLY `q[0]` while the rule accepts any near-tie within the slack: every kill made it jump between tied bodies, i.e. **the marker was stricter than the rule it was explaining**. `drillLegal()` now marks the whole slack set — bright head, dim ties — which is stable by construction. Measured: zero head-changes across 12 s of walking with no kills. **Learnability, two answers:** - the rule gets ONE modal, once per run, on the mode's own calm court (the seeded bodies are all at the far baseline — this is the popup policy's sanctioned case): *CLEARANCE DRILL — DROP WHOEVER WOULD REACH YOU FIRST · FAST BEATS CLOSE · STREAKS PAY*. "Most urgent first" is not guessable from a chevron alone; one sentence makes it learnable in the first five seconds. - **the markers are training wheels that take themselves off**: they hide at streak >= `drillMarkOff` (8) — the streak IS the evidence you can read the court — and any fault brings them back. The teacher steps in exactly when you demonstrably need it and out when you don't, and `drillMark: 0` still kills them outright. **THIRTEENTH PASS — ZOMBIE TENNIS, and the drill steps aside.** Oliver on triage: *"i think the normal player does not grasp this"* — the lesson across four drill designs being that **every invented rule needs teaching, and the only rules that don't are the ones the scene already implies**. Brainstormed again; he picked the dark horse: real tennis against the dead. One game per opponent, blood only. *"i can live simple tapping and timing"* — the tap-and-timing scheme, not the slingshot. **MATCH TEST in the back room (`#match`).** Built ON TOP of the duel machinery, deliberately: `G.match` runs a PERMANENT interlude — which is already what makes serving tennis-like, hides the rack, quiets the spawn director and wakes `racketAI`. The one change is in `resolveInterlude`: a settled point feeds `matchPoint` instead of ending anything. ~90 lines in beats.js for a whole mode, because the deepest system in the repo (swings, volleys, lanes, the AI) was already built. - **The score IS the body**: a quarter of the pool per point won, floored at 1 — a love game walks the racket zombie through every wound stage and the game point fells it (`explode` at the body). The scoreboard and the corpse agree all the way down. `matchOppHp` 4 + 2 per opponent felled. - **Its points draw your blood**: `G.hp--`, eaten at zero, the ordinary channel. Four clean points against you IS the run — `matchHeal` 2 per game won is how you last. - **Regicide inherited free**: smashing the ball into the body still wounds it, killing it outright wins the game early (killZombie -> resolveInterlude('P') lands with the court empty; the game is banked BY HAND there, since advancePoint never saw a game point). - **THE TIMING IS DRAWN** — his ask: *"what is the possible tap window what is the sweet spot"*. Two cues riding the mechanics' own numbers so the picture cannot lie: the APPROACH RING (a ring around the incoming ball that kisses a fixed inner ring exactly at `matchRingSweet` 2.7 m — the centre of the tapped-points benchmark's window — gold inside `matchRingBand`), and the APEX BAND (a guide beside the toss, gold across serveApexY ± the same 0.12 m keyPressed scores as perfect, a white tick riding the live ball). Match-only for now; they would serve plain tennis and the duel too if Oliver wants them there. - Verified by autopilot in the browser: 7 games in 90 simulated seconds, opponents 1..8, pools 4/6/8/10, wounds tracking points exactly, heal capping at hpMax. - **One harness trap that cost a round**: startMatch queues TWO first-sight modals (EXHIBITION MATCH, then the racket zombie's own) — and a dismiss during a modal's entrance (`m.in < 1`) is swallowed BY DESIGN. A scripted playtest must clear `G.modal` AND `G.modalQ`, or the world is frozen and sixty seconds of stepping measures nothing. **The drill stays in the back room beside it** — triage is a decent idea that failed the grasp-in-five-seconds test, and the match is now the lead candidate for the middle mode. **FOURTEENTH PASS — the match plays fair.** Oliver: *"the player is too weak in this mode. also the killing by smashing occurs too often. i cant really control it. maybe obsolete? also the serving after the first serve does not happen from behind the groundline"*. - **`matchPlayerHp` 8** — the match brings its own pool. The gore mode's 4 was never a decision here, just an inheritance, and a real game has rallies you deserve to lose without the run ending. - **REGICIDE IS OUT OF THE MATCH** (kept in the duel, where "any weapon counts" is the point of a sudden-death). In a full game, every hard serve that happened to meet the body was a free kill nobody aimed. A match ball NEVER damages the opponent now — it passes through like a soft ball and racketAI plays it — so the wound stages are exactly the scoreboard with no side-channel smearing them. This also deleted the hp-per-opponent escalation (a deeper pool changes nothing when the body is pure scoreboard): **the ladder escalates in racketAI instead** — `matchSpeedPer` (+12% speed per body felled) and `matchFlubKeep` (whiff chance × 0.82 per rung). - **Serving resets to BEHIND THE BASELINE with real deuce/ad mirroring** — `setupPoint`'s splatter branch ("serve from wherever you're standing") now excludes the match, which falls through to the tennis branch. Serving from mid-court where the last rally left you read as a bug because it was one. Verified: second serve from z 24.3, x mirrored to the ad side. **FIFTEENTH PASS — the opponent shows up, and the death loop dies.** Oliver: *"still very weak and slow opponent. it also spawns at places where it is impossible to react to my serve. so i win like always. in the cases i dont win there is a bug. when trying to serve after i got hurt i get hurt again and again until i die"*. - **THE DEATH LOOP was `G.serveNum`.** Tennis's `pointTo` is the only thing that ever reset it — and the match bypasses pointTo entirely. So the first fault of the match armed "second serve" PERMANENTLY: every missed serve after it was a double fault -> a point -> blood -> repeat to death, which is exactly the spiral a just-hurt, rattled player serves into. `matchReady()` (the match's own point boundary) resets it now, and a regression pins it. - **The receiver starts every point ON ITS MARK.** It was spawning at a random x and standing wherever the last rally abandoned it — an opponent that cannot reach the serve is not weak, it is absent, and every ace against it was hollow. `matchReady` drops it onto the exact receiver position the tennis branch computes for O (deuce/ad mirrored with the server). - **The base opponent is a player now**: `matchSpeedBase` 1.4 (the duel's 2.8 m/s lumber was tuned for ONE sudden-death point, not a game) and `matchFlub` 0.08 (the duel's 0.2 gives away a fifth of all returns — an opponent that beats itself). Measured with the same autopilot that previously won seven straight games: **it now loses the match** (2-3, then blooded out in game two). That may even be too hot — `matchSpeedBase`/`matchFlub` are the two dials, and Oliver's thumb is the judge. **SIXTEENTH PASS — the REAL death loop, and my serveNum fix was a red herring.** Oliver, again: *"still when I lost a single point and try to serve again every further tap does hurt me more until I am dead"*. Reproduced tap-for-tap this time before touching anything, and the previous diagnosis was wrong — serveNum was A bug, but not THE bug. **`tossBall` never stamped `ball.lastHit`.** After any rally the zombie touched last, the freshly tossed ball still said `lastHit: 'O'` — and the fourteenth pass moved the server BEHIND the baseline (z 24.3, past the passing-shot line at COURT.L + 0.3). So the moment the toss went up, the game saw "an opponent ball beyond your baseline": *its passing shot ends the duel* -> point to them -> blood. Every tap = a toss = an instant lost point, faster the more he panicked. Two of my fixes composed into a death loop: the stale field was harmless while serving happened INSIDE the court. One stamp in `tossBall` (`ball.lastHit = sv.side` — the toss is the server's ball) fixes every caller. **The duel and the gore mode carried the same latent bug** for anyone tossing from deep behind the line after a racket-zombie touch; it never fired only because splatter serving used to happen from inside the court. Verified in the browser with the exact field shape: lose a rally the zombie touched last, then eight badly-timed panic taps — one honestly lost point, zero toss-bleeds. **The lesson for this file: when a report survives a fix, the fix was wrong — reproduce before re-diagnosing.** The serveNum reset was real and stays, but it was found by reading code, not by reproducing the report, and the actual loop needed the exact field conditions (a zombie touch, the new serve position, panic-speed taps) to show itself. **SEVENTEENTH PASS — "every second ball is unreachable" was the player, not the CPU.** Oliver: *"the opponent is really hard to beat now.. every second ball is basically unreachable by an auto-run player.. are there some hidden gestures I don't know about or is it just a very strong cpu?"* Neither: **the match had inherited gore-splatter's depth-only recovery** (the fifteenth-pass sidestep-protection rule) by virtue of being `G.mode 'splatter'` — so one wide return pulled you off centre and you STAYED there, and the next ball to the opposite corner was lost before it was struck. A player who never walks back to centre experiences every rally as "unreachable"; it read as CPU strength because the balls kept landing where he wasn't. - The match recovers to CENTRE now, like tennis — real footwork, and in a rally there is no artillery lane to protect. Gore splatter keeps the depth-only rule; the sidestep it protects has no role inside a rally. - `matchSpeedBase` 1.4 -> 1.25: it was calibrated against the broken recovery, i.e. against a player who couldn't move. With footwork restored the ladder starts gentler and still climbs. - For the record, the full attacking vocabulary — nothing else is hidden: timing quality (the ring and the band), PLACEMENT by flicking/holding a direction during the swing (`doHit`'s aim — deep, short, wide; wrong-footing is how points are won, the AI recovers slowly on purpose), VOLLEYS (stepping in to take the ball pre-bounce carries reach x1.4 / window x1.8), and serve pace off the apex. The placement flick is taught nowhere on touch — worth a ghost-hand lesson if the mode ships. **EIGHTEENTH PASS — the flick shows itself, the width is a dial, the blood stays on.** - **THE PLACEMENT RING**: an aimed rally hit (flick or held direction at contact) stamps `G.placeMark` and a gold ring fades where the shot is going — flick, watch it bite the corner, learn the cause. Deliberately NOT stamped for unaimed hits: marking the scatter would teach noise. Fades in 0.8 s; a permanent ring would read as a target to hit rather than feedback. - **`matchWideBase` 2.1 / `matchWidePer` 0.4 — the opponent's WIDTH is the ladder's third dial.** Its returns scatter off `aimError x (1 - quality)`, which painted lines from game one ("still a LOT of unreachable balls"). Opponent one now keeps its returns inside what an auto-run player can actually cover (measured: 25 returns, all inside ±2.2 m), and earns 0.4 m more width per rung — line-painting is the LATE ladder. Speed, reliability, width: three dials, all per-rung. - **THE BLOOD VIGNETTE replaces the temporary border.** The old edge wash flashed 0.3 s and vanished — between hits the screen said you were fine. Now a radial vignette reads `G.hp` directly: its depth IS your damage fraction, permanent until healed, and the hit pulse rides on top. Global (the hp channel is global), not match-only. Same no-HP-bar philosophy, one layer out: the state is worn, not reported. At 1 hp the frame is properly blood-rimmed and the court centre stays legible. **NINETEENTH PASS — pace was the missing dial, and the reachability test finally exists.** Oliver: *"the vignette can get more pronounced and there are still balls which are basically not reachable with auto run.. please recheck"* — plus, asked twice and deserving a straight answer: *"did you write tests for the last issues we encountered?"* **No — the unreachable-balls issue only ever had config assertions, which is exactly why it came back twice.** - **Pace, the ladder's FOURTH dial** (`matchOppPace` 0.78, `matchOppPacePer` 0.05/rung): width alone never fixed reachability because the returns left at ~27 m/s — crossing to your baseline in ~0.6 s, less than auto-run needs from the far corner. Opponent one now returns at ~20 m/s (measured live: twelve returns, 20.0-20.7), a quarter more flight time; the rungs take it back. - **The vignette deepens**: `dmg * 0.8` (was 0.5), cap 0.85, and the clean centre SHRINKS with damage rather than only the rim darkening. - **The REACHABILITY REGRESSION** (the test this issue always deserved): a headless 90 s autopilot soak — auto-run, fixed tap window, no placement — asserting ≥10 returns sampled and ≤15% that never came within volley reach. Sabotage-verified: cranked knobs trip the suite. **Its honest limit, recorded so nobody overtrusts it**: the autopilot attends perfectly and taps frame-precisely, so it OUT-COVERS a human — knob-legal-but-hostile values still pass it. It is a floor against catastrophic regressions, not a proxy for Oliver's thumb; his playtest stays the real gate for "reachable". **TWENTIETH PASS — the flight recorder, and the statue with a racket.** Oliver asked to be WATCHED: *"can you watch me play in the browser pane and see why I loose"*. A passive 20 Hz recorder was injected (samples score/ball/player/opponent, keeps the last 1.5 s of trajectory before every lost point, touches nothing) and he played a real match in the pane. Seven losses on record, and the verdict was unambiguous — **he swung in every single lost point (4-5 swing frames each) while the ball died 2-3 m to his side, and his own x never moved all game.** His timing was fine. He had no legs: `autoAssist` keys off `pointer: coarse`, the pane reports a FINE pointer, so auto-run was 0 — the exact trap the verification recipe has warned about for weeks, now bitten by the designer himself in live play. - **`matchAssist` 1.15: the match brings its own legs on every device.** It is a tap-timing game; footwork is the game's job. Held keys still override (the assist only runs with no manual input). Pinned by a test that plays with `autoAssist` 0 — the desktop condition — and asserts he runs. - **The vignette curve is sqrt(damage)**, not linear: the match pool is 8, so hit one was a 10% alpha nobody can see ("basically invisible"). sqrt front-loads it — first blood reads at ~0.3 and it still deepens to the cap. - **The flight-recorder pattern is worth keeping**: instrument passively, let Oliver play, read the trails. It ended a three-round guessing match in one session — his reports and my autopilot measurements kept disagreeing because the autopilot had legs he did not. **TWENTY-FIRST PASS — the short ball, the home-bound intercept, and the portrait vignette.** Second recorded session: 5 wins and a deuce war to 5-6 (the mode is a contest now), but all seven losses shared one signature — the ball DIED IN COURT 1.4-2 m short of him while he ran 0.2-0.5 m. Plus his note *"there seems to be a problem when the ball arrives right from the player"* and *"still no vignette. just a slight red pulsing from below"*. Three distinct defects: - **The pace cut created DYING ROLLERS**: slow returns bounce and die at z 18-20 — outside `intercept`'s near zone, so nothing ever chased them, and the rolled-dead rule gives the point to the last hitter (the zombie). They also arrive at his feet below legal contact height, which is the "ball arrives right at the player" feel. `intercept` now keeps an `anyHit` candidate — the earliest REACHABLE point on his half where the ball is still at hitting height — so the assist runs FORWARD onto the short ball, which is the tennis answer. (Zone points still win: deep is better.) Verified: the chase test shows him sprinting z 23 -> 16.4 and meeting the ball at gap ~0. - **`intercept` computed reachability with `autoAssist`** — 0 on any fine pointer — so a desktop match's intercept thought nothing was reachable and answered "home" every time: he had legs (matchAssist) but his destination was always the baseline centre. `assistK()` is the shared helper now; both the mover and the planner use it. - **THE VIGNETTE WAS RADIAL IN A PORTRAIT CANVAS.** The circle reaches the corners but arrives at the left/right screen edges at fraction ZERO — "red pulsing from below" was the bottom edge being the only place the circle got close. It is FOUR LINEAR EDGE FADES now, hugging the actual rectangle on any aspect, sqrt(dmg) curve kept, inset deepening with damage. Posed at 6/8 hp: all four edges carry visible red. - **Test-measurement trap for the log**: reading `P.z` after the chase loop proves nothing — the point reset teleports him home. Measure the minimum DURING the chase. **TWENTY-SECOND PASS — the longer racket.** Third recorded session, and the previous fixes did their job while exposing the last gap: he ran onto every ball (2.9-3.9 m per point, was 0.2-0.5), the ball reached him (died at z 22.5-24, was 18-20), he swung every single time — and the closest approach clustered at a sampled 1.3-1.6 m, centimetres outside the 1.3 m post-bounce reach, point after point. The assist can deliver you NEAR the ball but not inside a phone-tuned contact radius. `matchReach` 1.35 widens the contact test in the match only (gore modes untouched — their "tight contact radius: the tap must be honest" rule stands). A rally mode lives on contact feeling fair. Note for future recorder sessions: **a 20 Hz sampler overstates closest approach by up to ~0.5 m** on a 20 m/s ball — the recorder now samples at 60 Hz. **TWENTY-THIRD PASS — first game won, and the rungs flatten.** Fourth recorded session, his verdict "better": **he beat opponent one — the first game won in any recorded session — and reached opponent two,** which then took him apart with gaps of 1.66-2.13 m (60 Hz, accurate) while he swung at closest approach every time. The ladder gradient is real; the STEP was a wall, because every rung moves three dials at once (speed, width, pace, plus flub). Per-rung increments softened: `matchSpeedPer` .12->.08, `matchWidePer` .4->.25, `matchOppPacePer` .05->.035. Opponent one is untouched — the bases stay; only the slope flattens. The contact chain is now verified end to end by recorded play: LEGS (matchAssist) -> DESTINATION (assistK in intercept + the anyHit short-ball chase) -> REACH (matchReach). Each link was a separate defect found by a separate recorded session. The flight recorder earned a permanent place in the toolbox: passive 20-60 Hz sampling with pre-loss trails ended four rounds of "still the same" in four sessions, where autopilot measurement had failed for three passes straight. **Open, and every one of them is Oliver's call after he plays it:** 1. ~~**A ball that carries through the lane can complete two steps at once.**~~ Largely CLOSED by the slice removal (seventh pass) — a cut ball picking its own next victim was the main way this happened. A straight ball can still plow through a body standing directly behind another (`TUNE.zPierce`), which is honest occlusion rather than the game aiming for you. Watch it. 2. ~~**The tap aims itself.**~~ CLOSED by the second pass above — the snap is gone, you aim. 3. ~~Difficulty climbs on sequence LENGTH.~~ CLOSED by the stream — one ramp on time survived now drives walk speed, arrival rate and round length together. What is still NOT ramped is how often the called order fights the geometry; with lanes held, that is a question of which bodies get called rather than of where they stand. 4. Nothing is wired to tennis scoring, the menu or the run, and the mode has no ending. That is the next job IF the answer to "tense or noisy?" comes back tense. 5. **A shambler takes two splat-quality hits.** In a mode where every shot is aimed by hand that is two aimed serves per body, which paces a round more than the order does. Worth checking on feel whether drill bodies should be one-shot glass. ## 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 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. **The popup policy changed: a modal is for CALM courts only.** The report was "popups block interaction while the game loop continues and the zombies arrive". Measured first, per the meta-lesson: the world genuinely FREEZES behind a modal (a shambler moved 0.000 m over 3 s with one up — the invariant holds). What kills is the CONTROL steal: a popup lands mid-crowd, eats the next tap, and hands the court back with the player's rhythm gone. So every announcement that can land on a live court is a CARD now (non-blocking): the weapon first-sight modals are deleted outright (deliverTin's card and popBarrel's new card carry the one desc line that matters), and the barrel/machine/racket introductions are first-sight cards. The horde popup shows as a modal ONCE per run (it changes the control mode) and every later set starts on the flash alone — re-teaching the same rules at the hottest moment of every set was the worst offender. Still modals: sudden death, the chapter boss, WOUNDS TREATED, test mode — all fire on guaranteed-calm courts. **The transition howto modal is gone; a GHOST HAND teaches instead.** "Nobody seems to read this." `drawGhostHand` (render.js) is a drawn pointing hand — canvas paths, UI palette, deliberately not a sprite — with `drawTapRing` for the contact ripple that makes a mimed tap read as a tap. The apron demo's old thumb-dot now rides its strokes, and the transition primes `G.idleHintT` so the pull demo starts miming the moment splatter begins. TENNIS got three lessons on the same hand, each keyed to the exact failure it answers and reset by doing the thing: idle in the serve stance → tap demo on the apron (`drawTapHint`); `G.tossDrops` ≥ 2 → the hand rides the toss and PRESSES AT THE APEX (`drawApexHint` — the timing is the lesson); `G.passStreak` ≥ 2 untouched passes → the hand presses as the ball closes (`drawHitHint`). **The opening CPU is beatable in a game or two now.** Testers ground for rounds. `aiFlub` 0.09 → 0.18, `aiQualityMax` 0.85 → 0.72, plus a rubber band: every game the CPU takes adds `aiFlubLoss` (0.08) to its whiff chance. Tennis mode only exists before the first transition, so these are purely the opening-match dials (`racket*` covers the duels). **The old "CPU returns ~91.7%" benchmark is deliberately retired by this** — the expected first-swing return rate is now ~82% at 0–0 and falls as the CPU loses games. **A shot barrel no longer yanks the new weapon into your hand.** `popBarrel` restores `G.weapon` after `grantWeapon` and pulses the new slot instead (`G.rackGlow`, drawn in drawRack) — "the current weapon should stay active, the new slot should glow briefly". The duel's `deliverTin` still auto-equips; that one lands on a calm court and IS the prize. **Switching weapons clicks** (`assets/audio/weapon-click2.mp3`, 0.45 gain — `sfx()` now accepts names carrying their own extension) **and the live rack tile is unmistakable**: brighter well, 2 px gold frame. **Barrels ramp with the run** (`barrelRamp`): the gap between barrels shrinks by one every 8 finished games, floored at every game (g0–7 every 3rd, g8–15 every 2nd, g16+ every game). The late game was "fast tapping until your thumb gives out" once the tins capped; the rack is the strategic answer, so the run keeps feeding it. **The umpire triple-tap cheat is deleted** — the test group found it. `T.forceZombies()` remains. **Third batch, same day — REACTIONS LEFT THE BAND (option C, settled in discussion).** Oliver's "reactions play after shooting, not when the ball hits" was MEASURED and real: `praise()` fired in hitServe at RELEASE while the ball flew for another half second. The word is now ARMED on `ball.praise` and pops at the IMPACT — first body the ball touches (zombies.js contact branch) or its first bounce (onBounce); a rally hit pops at the racket, where contact and praise are the same moment. Verified: a 32-frame flight popped its word on frame 32. Reactions are **world-anchored pop texts** now (`pops`/`popText` in zombies.js, drawn in the world pass before the un-shake): DOUBLE/TRIPLE KILL and MASSACRE rise off the corpse, RETURNED off the parry, praise off the impact. Small (Bangers 13), rise-and-fade in 0.85 s, capped at 10. **The band got ROWS**: flash line 68..90, countdown under it, and `bandTop` floors a card at 96 so a tall icon card can no longer print over the flash — that collision was the "spamming over each other" report. **Cuts, per Oliver**: the GAME N flash is deleted (with the whole `pendingGame` latch — the scoreboard bar prints the number permanently) and the CHANGEOVER flash is folded into the countdown panel ('CHANGEOVER · RESUMING IN n'). He kept the burst-arm weapon label and the praise words. **A bottom/above-rack message slot was proposed and REJECTED**: that is thumb territory — covered by the hand mid-pull and outside where the eyes are. **Sixth batch, same day — the live card-tuning session (Oliver in the preview pane).** Settled by looking, together: the card is `CARD_PAD` 20 with the icon boxed to 46×44 and `CARD_TOP` 88 (flush under the flash row). And the RESUMING countdown left the band entirely: **Bangers 16, gold, shadowed, floating just above the net tape — no panel, no border.** His call: the countdown belongs to the court the pause is holding, and the shout face survives any backdrop, which retires the panel that was added for clay-crowd readability two batches ago. The changeover still names itself in the same line ('CHANGEOVER · RESUMING IN n'). **Fifth batch, same day — THE BATTERY PASS.** "Drains battery like there is no tomorrow." Measured first: update() 0.01 ms, render() 0.15 ms per frame — the JS is nothing. The bill is fill-rate × frame-rate (a 3x iPhone pushes a ~1080×2400 canvas through the compositor 60×/s) plus 60 Hz wakeups on static screens. Three levers shipped: - **The canvas is OPAQUE now** (`dom.js`: `getContext('2d', {alpha:false, desynchronized:true})`) — render() clears the whole frame to a solid colour anyway, so the compositor no longer alpha- blends 2.5 Mpx sixty times a second. Free. - **Calm states run slow** (`calmFps()` in main.js): a SETTLED modal (world frozen, only the blink moves) runs at 20 fps, the death summary once faded at 24. Live play stays at TUNE.fps — the 60 was a feel decision and the governor deliberately never touches anything that moves. - **`TUNE.lowPower`** (>= 0.5, T-panel-flippable live): caps RES at 3 — −44% of pixels painted per frame, THE big lever. `RES` is `export let` now, re-derived in fitViewport; main.js re-fits when the knob flips. Sprites are 224px sources so RES 3 draws them at-or-below 1:1: softer, never mushy. **DEFAULT ON since 2026-08-08** — Oliver judged it "looks fine" on his phone. Set `lowPower: 0` for the full-fat render; if it ever becomes player-facing it should be a settings toggle, not a TUNE knob. **Fourth batch, same day — the card went HORIZONTAL, and nothing draws under a modal.** The stacked icon card ran 102 px tall and its lower third hung over live court; the icon sits LEFT of the text now (boxed to 58×52 so a wide tin cannot reach the type — and it draws BIGGER than the stacked layout gave it), so every card is a flat `CARD_H` 62 px at fixed `CARD_TOP` 96, fully over the crowd. `bandTop()` is gone with it. And a modal owns the whole screen now: the flash and the card are neither DRAWN under its dim nor TICKED behind the freeze (the card tick moved below the modal early-return, next to the flash's) — a card raised in the same beat as a popup used to burn its whole life dimmed-out underneath it, and now it holds and plays after the dismissal. **Second batch, same day — THE MESSAGE BAND.** Oliver: transient messages need "a definite space … over the crowd". `AD_TOP()`/`bandTop(h)` in render.js pin the whole family there — below the scoreboard block, above the ad strip, never on the court: - **drawCard** anchors in the band instead of centre court (a card at H/2 covered the very play it announced, non-blocking or not) and its title dropped B(30) → B(22) ("the heading is a little too big"). FLASH_Y (68) already lived in the band and is unchanged. - **RESUMING IN n sits on its own PANEL** (the info-bar style) under the flash slot — bare shadowed text was unreadable over the sunlit clay crowd, a complaint Oliver had been carrying "forever". It YIELDS to a live card: both draw in the band, and the card is the announcement of the pause. - **The gesture-teaching strings are deleted from the METER_Y slot** ('PULL DOWN TO AIM…', 'TAP TO TOSS, TAP TO SERVE', 'PULL DOWN: FURTHER = HARDER') — the ghost hand is the teacher, and the same lesson in two registers was clutter. The slot now only carries STATE the mime cannot: which weapon the serve fires, the gun-unload line, SECOND SERVE. - **Ghost hand v2**: 1.4x default scale, hint type F(8) → F(10), and a real silhouette — extended index finger with a nail hint, three curled-knuckle bumps, thumb out to the side. The first cut "could be recognized, but…"; the knuckle bumps are what flipped it from mitten to pointing hand. ## Session log: the sidestep, and two type registers (2026-08-03) Oliver's post-ship playtest. One blocker and seven polish items; all eight landed. **The blocker: he died at every chapter boss because he could not move.** Two independent causes, and fixing either alone would have left him rooted. The movement block is `rally`-only (a settled decision the six boss AI styles were written around), and a boss fight is spent in `pretoss`. On top of that `canAimServe()` is true through a boss fight, so the one finger was already spent on the slingshot — there was no gesture left over that *could* mean "move". The answer is a **sidestep**, not a walk, so the settled decision stands; and the gesture is picked by the DIRECTION the drag leaves the deadzone in, so nothing about "one gesture means one thing" bends. Both are in the settled-decisions list above. Two guards make the dodge worth spending: `hurtPlayer` (the ranged channel) and the contact test in `updateZombies` (the instant-death channel). Verified by pinning a shambler on the player: alive at `dodgeI` 0.3, eaten the frame it hit 0. **The message system was seven unrelated looks; it is two registers now.** Bangers is bundled alongside Silkscreen and the split is documented above. `drawCard`, `drawModal`'s title, `G.flash` and the death summary shout; the control hints, the pull/dodge demos, the scoreboard and `RESUMING IN` stay quiet. The types Oliver had not counted, for the record: the bottom control-hint slot at `METER_Y`, the `drawPullHint` apron demo, the death summary, and the scoreboard/rack chrome. **Modals queue now.** `showModal` used to *drop* a second popup opened in the same beat, which is why the trainer and the weapon had to be crammed into one card after a won duel. `G.modalQ` is four lines and it retires that hack: the weapon modal, then a real `WOUNDS TREATED` modal with the `x_cross` first-aid tin over it (the sprite had been preloaded and drawn by nothing since the merge). **One popup, one rule.** `weaponModal`'s first-sight gate moved INSIDE the function. The barrels gated themselves and the tin ladder did not, so four weapons re-explained themselves every three games for a whole run while a barrel weapon explained itself once. `deliverTin` gained the label flash a barrel already had, or the fifth delivery onward would pay out in silence. **Ten rack slots, second row on demand.** `RACK_MAX` 4 → 9. Ten tiles across 480px would be ~39 CSS px on a phone, under the thumb target `rackHit` was widened for, so past five it grows a second row UPWARD and slots 0-4 stay welded to the bottom edge. `METER_Y` derives from `rackTop()` now, so the hint line and the apron demo lift with it. **The apron demo alternates.** `demoSwipe(dx, dy, label)` is the old `drawPullHint` body with the direction pulled out; `drawPullHint` alternates PULL DOWN and SWIPE TO DODGE on the 1.9s cycle, and mimes away from whichever edge the player is standing against. **The transition punches in.** `G.zoom` is a flat 2D scale about a projected world point, applied in the same place and for the same reason as `G.shake` — a perspective dolly would want `camZ`, `aimCam` and every projected HUD anchor to agree for the sake of one 1.15s beat. Escalating shake at 3.4 / 4.8 / 8.2 s. **And the boss win holds.** `TUNE.bossWinT` (3.6s) is its own number so the routine SET card does not drag with it. `EPIC!` in Bangers, chapter and crowd demoted to subs, and no `GAME n NEXT` line — the bar under the scoreboard already prints the game number permanently. ## Session log: the second pass on the barrel weapons (2026-08-03) **The auto-fire jog is gone, and it was one guard.** `gameplay` skipped the pose update in `mgSteer` mode (`if (!mgSteer && ...) P.pose = moving ? 'run' : 'idle'`), so the pose stuck on 'idle' — and `render` covered for it by faking a run cycle off `G.idleT`, which animated whether he was moving or not. That fake is what Oliver saw: *"this hectic run animation... looked odd all the time."* The pose follows MOVEMENT in every mode now and the fake is deleted, so auto-fire (and the horde, and the melee racket) uses the ordinary run animation and standing still is standing still. The comment that justified the fake — "the idle sprite has a racket at his hip AND drawMgRacket puts one in his hands, two rackets" — was solving a smaller problem than it caused; at sprite scale the overlap does not read. **The laser, second pass.** Four things, all of them "it must read as light he fired": - **It draws AFTER the net and every body.** It was drawn before the far-side pass, so the net and the crowd painted over it — which is precisely why it read as lying on the floor and being *under* the net. Light in the air goes last. - **`beamLen` 26 -> 34.** At 26 it stopped a metre past the far baseline and read as being CUT OFF in mid-court. It now carries into the courtside ads, which is where light should stop. - **`beamW0` 0.35 -> 0.06** — a POINT at the strings. At 0.35 it left the racket as a 0.7 m bar hanging in mid-air rather than as something emitted. - **It DELETES ordinary bodies**, hp irrelevant (`killZombie`, not a wound) — a plane of light that left a roller man standing made it a worse ball, not a weapon. A BOSS is the exception and takes `dmgBeam` 8: "they also must suffer severely" but they survive. - **The look is four nested wedges composited `lighter`** — violet bloom, the weapon's blue, cyan, white core — each fading along its length. One quad with a gradient was a coloured shape; layers sharing an axis and brightening toward the centre is what reads as sci-fi. **Rocket:** speed 17 -> 38 and a real JET plume. The plume is laid down tight ON the flight line (`jet`, spread along this frame's step, near-zero velocity, additive hot-white -> orange -> grey) instead of scattered sparks with their own velocities, which looked like a firework. **Beacon:** a finer trail (`trail` 1 at `trailR` 0.035, one-pixel motes) and a much brighter ball (`glow` 3.2, a two-ring additive bloom and a fat white core), so the BALL is the bright thing and the trail only says where it has been. **The bug in the middle of all that:** `firePellet` stamps a whitelist of fields onto the round, and `jet`/`trailR` were added to the WEAPONS rows without being added to that list — so the rocket flew with no plume at all and nothing errored. **Any new pellet field has to be added in both places.** ## Session log: fire, ash, and the third laser pass (2026-08-03) **A BURNT BODY DOES NOT BURST.** Fire kills by cooking, so there is nothing left to spray: a body that dies while alight — flamethrower or flame ball — leaves **dark grey ash** instead of blood. Same decal, same mound, same particles, all recoloured and a little smaller, plus a puff of smoke and a dull thud instead of the wet splat and the headshot crack. `killZombie` reads `zb.burnT > 0 || zb.ashes`; **the flame BALL has to set `zb.ashes` itself**, because that kill happens before the body ever catches. `gore`/`piles`/`parts` all carry an `ash` flag and render off `ASH_COLS`. **The flamethrower is a lighter, not a gun.** `dmg: 0, hp: 0, pierce: true` — it does NO impact damage at all and only IGNITES; the burn does the killing, so a lit crowd walks a few steps and goes down together. Reach `life` 1.15 s x `speed` 23 = **26 m against a 22.8 m court**, so it crosses to the far baseline. Watch the guard in the pellet loop: `hp -= 0` fell straight through to `killZombie`, which made the flame cone delete the crowd on contact — a machine gun that happens to be orange. **Every burst weapon had no progress bar, and the cause was the spend.** Arming spent the tin immediately, so the rack entry vanished — and the countdown bar draws on the slot holding `G.gunId`, which no longer existed. The tin is spent when the burst ENDS now (`endBurst`), so the icon, the count and the bar are all there while it runs. **Every path that ends a burst must route through `endBurst`** — the duel, the boss fight and death all force `G.mgT = 0` on their own, and any one of them missed would hand out free ammo. The soak asserts `gunId` is never set with `mgT` at zero. **Auto-fire movement, again.** The previous fix set the pose from `moving`, but `moving` was `mx || mz || assist`, and finger-steering zeroes `mx`/`mz` — so a player sliding left and right under auto-fire still counted as standing still and never ran. `mgMoving` is in that expression now. **The mg racket is GONE.** It was a procedural racket drawn over a sprite that already holds one: straight up-court it projected onto the player's back as a floating oval, and offset to the side it just stuck out. Oliver: "can we please just hide it?" The pellets streaming up-court are the feedback. `drawMgRacket` is deleted, not commented out. **(Previously) the oval on his back was the mg racket.** `drawMgRacket` put its head at `(P.x, 0.95, P.z - 1.35)`, which at this camera projects almost exactly onto the player sprite. Offset by `P.facing` it reads as a racket held out beside him. **A third array had the empties-under-the-iterator bug.** A rocket blast can bank the last kill of a game, and the beat that follows (a duel, a chapter boss) does `pellets.length = 0` — so the pellet loop's next index read undefined and the frame threw. `shots`, `pellets`, `extras` and the body loop all carry the same `if (!x) continue` guard now. **Any array iterated in `updateZombies` that can contain something whose death advances the game needs it.** **A gradient that ends at alpha 0 hides the thing you are aligning.** The laser's far corners were pinned correctly from the first attempt, but each wedge layer faded to fully transparent along its length — so the beam became invisible before it reached the pinned edge, the VISIBLE end sat well short of it, and moving the geometry appeared to do nothing twice in a row. The far stop is lit (0.62 of the layer's alpha) now, so the visible edge and the pinned edge are the same line. **The beam falls off with distance** — brightest at the strings, thinning down the court, because that is what a beam travelling away from you does. The far stop is 0.32 of the layer's alpha: enough depth to read, never zero, so the visible end stays the pinned end. **The laser's far edge is pinned in SCREEN space.** Running out to `beamLen` on the ground landed it on the ads' TOP line; it should die at their middle. `c3.y = c4.y = proj(0,0,-6).y - AD_H / 2` — the x's keep their perspective, the y is pinned. That holds in both orientations and leaves `beamLen` free to be the KILL range instead of a number tuned to one camera. **Weapon sounds** are a `sfx` field on the WEAPONS row: `thump` (beacon, `boom`), `swoosh` (rocket, `whoosh`), otherwise the gun's every-other-round tick. ## THE BLINKING BODIES, and why a missing frame is worse than a missing sheet (2026-08-03) Oliver: "on iphone the zombies were blinking in and out of existence". **`drawZombie` validated its frame count against the LAST frame only** — `if (!SPR['z_' + v + N]) N = 2` — so a set with, say, `z_h2` missing but `z_h4` present kept `N = 4`, the walk cycle stepped onto a name that had never loaded, and `drawStatic` silently drew NOTHING. The body vanished for that frame of the cycle and came back on the next one, forever. A single cached 404 (this project's oldest trap) is enough. `if (!SPR[name] && SPR['z_' + v + '1']) name = 'z_' + v + '1';` — hold frame one. **A body that stops animating is a bug you can see and diagnose; one that flickers in and out reads as the game breaking.** Every silent `SPR[x] ? x : fallback` in this file deserves the same suspicion. **And no `ctx.filter`, anywhere.** The cut line was briefly the sprite redrawn clipped to a band and flattened to one colour by a canvas filter, which put it exactly on the silhouette. On an iPhone the line did not draw AT ALL. Whether that was the filter being unsupported or leaking past its `restore()`, a rendering feature that fails silently on the target device is not worth a pixel of polish — the line is a plain bar now and crosses the odd gap between the legs, which is the trade. ## The laser plane, and cutting bodies with it (2026-08-03) Settled with Oliver in discussion, then built. **The beam is a real plane at `TUNE.beamY` (1.15 m)** that holds that height all the way out instead of sloping to the ground — that single change is what makes the rest possible. Sloping, it lay flat on the grass across the whole far half, so there was nothing for it to cut exactly where the crowd stands. - **`beamLen` is the DRAWN length now, not just the kill range.** It used to be solved every frame so the far end landed on the middle of the ad strip, which made it a knob that visibly did nothing. One meaning beats one clever behaviour. - **`TUNE.beamBand` is a live A/B between two looks**, flippable from the T panel on the phone. **1 (default) = the BAND**: drawn the way the ball's aim trajectory is drawn — a path down the lane whose width comes from each point's OWN projection scale, so it narrows into the distance like everything else on the court, with a shadow on the lawn to pin it there. **0 = the CONE**: the solid widening triangle. The cone IS a true projection, but it grows in world width (0.06 m -> `beamW1`), so on screen it opens out while the court recedes — and the eye reads that as a flat shape stuck to the screen. Oliver: "it does not look correct perspective wise". **The KILL follows whichever look is on** (`beamBandW * 2` vs the cone's taper) in BOTH `fireBeam` and `drawBeamCuts`, or the comparison would be a lie — measured, the cone takes 9 of 9 bodies in a lane and the band 5 of 9. - **THE PLANE IS OPAQUE, and that is what settled it.** While it was translucent, its opacity and the over/under read were the same dial pulling in opposite directions: readable enough to show the split and it veiled the whole cone, subtle enough to look right and you could not tell above from below at all. Solid colour ends the argument — a body above the plane is drawn on top of it, a body below is simply hidden behind it. **Depth is carried by BRIGHTNESS** (full at the strings, half at the ads) rather than by alpha, which is why it still reads as receding. `BEAM_EDGE`/`BEAM_MID`/ `BEAM_CORE` are the three colours. The trade is that you cannot see the crowd through it. - **The fill is TWO gradients**, matching the reference Oliver sent: a horizontal one across the width (near-white down the centre line, falling to the weapon's blue at the edges) and a depth one along the length. Canvas has no 2D gradient, so the wedge is filled in eight slices along its length, each with its own across-gradient at that slice's depth alpha — enough slices that the seams vanish. The two long edges are STROKED: the reference reads as a plane because its edges are drawn, not merely where the fill stops. - **One flat translucent triangle**, apex at the player's CHEST (`beamOriginY` 1.25 — emitted from the racket head at 2.2 m the apex hung in the air and read as detached), opening out down the court, alpha falling with distance. It was four nested wedges composited `lighter`, and that layering WAS the banding: additive light blowing out to white where layers overlapped. Flat alpha also means a body under the plane is TINTED rather than brightened, which is what makes the cut read. - **Bodies are sliced by it.** `drawBeamCuts` runs after the beam: for each body in the cone, the part above `proj(zb.x, beamY, zb.z).y` is redrawn clipped, so it sits ON TOP of the plane while the part below stays tinted underneath. `drawZombie` mutates nothing, so drawing it twice is safe. Measured: the plane takes a shambler at 64% of its height (sternum), a brute at 43% (belly), the roller man at 36% (thigh) — bigger bodies visibly take it lower. - **The cut line follows the SPRITE, not a rectangle.** A `fillRect` spanned the body's bounding width and so crossed the empty gaps between the legs and under a held-out arm, reading as a bar lying behind him. The line is the sprite itself, redrawn clipped to a thin band and recoloured flat by `ctx.filter`. Thickness scales with `h * s²` so it is a hairline at the ads and a real band at your feet — the camera's scale only spans ~1.5x across the court, so a linear falloff was invisible. - **Getting a filter to a specific colour is a MEASUREMENT, not a guess.** The chain has to leave a MID grey for `sepia` to tint: `brightness(0) invert(1)` goes to white, and sepia adds almost no chroma to white while saturating a colourless pixel does nothing — the first attempt rendered #a0b4c3, a grey. `brightness(0) invert(0.45) sepia(1) saturate(1600%) hue-rotate(185deg)` renders (32,154,255) against the laser icon's (29,153,232). Probe candidate chains by filling a rect and reading the pixel back; do not eyeball it. - **`HAS_FILTER` is feature-detected** and the line falls back to a narrow bar without it (older WebKit has no `ctx.filter`). - **The cut pass is BOUNDED** (`CUT_MAX` 8 nearest bodies, `CUT_MIN_PX` 14): it draws each body twice more and the second pass runs through a filter, which is slow on iOS, and it runs every frame you are AIMING rather than only on the shot. Measured on desktop: 0.27 ms/frame with 20 bodies against a 16.7 ms budget. ## T.laserScene() — the tuning playground `T.laserScene()` in the console (or `#laser` on the URL) poses six bodies of different types at known depths with the laser armed and a pull held. `T.laserFire()` shoots it; `T.laserScene()` re-poses. **It does NOT pause, and that is the point.** The first version froze the world so the picture would hold still — and then every value changed in the T panel did nothing visible, because nothing redrew. Oliver: "tuning the parameters does not change the view (because it's paused?)". The bodies are held still individually (`v = 0`) and spawning is off instead, so the picture is stable while the render loop keeps running. **Never pose a tuning scene by halting rAF or pausing** — a still frame you cannot change is worth nothing. **A boolean living in TUNE must be tested with `>= 0.5`.** The panel renders every TUNE key as a number input stepping by 0.02, so one nudge on `beamBand` set it to 0.02 — still truthy — and the renderer silently switched variants under him mid-tune. The laser's shape was tuned this way and these are Oliver's numbers: `beamY` 0.95, `beamLen` 23, `beamW0` 0.08, `beamW1` 3.1, `beamOriginLift` 0.44, `beamBorder` 0.18. **`beamY` and `beamLen` interact** — a lower plane meets the ad strip at a shorter distance, so dropping one wants the other shortened. ## T.pause(true) — freeze the world, keep drawing `T.pause(true)` stops `update()` but keeps `render()` running, so a value changed in devtools shows on the next frame while the scene stands still. The older recipe halted rAF (`requestAnimationFrame = () => 0`), which freezes the PICTURE too and is no use for judging how something looks. `T.pause(false)` runs again. ## TEST MODE — triple-tap the scoreboard A sandbox for trying new content without playing twenty games to reach it. Three taps on the scoreboard (main.js; the umpire's neighbouring triple-tap cheat is deleted) toggles `G.testMode`; three more turns it off, and a SINGLE tap is still the miss-log overlay. - a fixed MEDIUM wave: `testZMax` 8 bodies, `testZSpeed` 1.15 m/s, `testSpawnEvery` 1.6 s — it overrides the difficulty director rather than editing it, so the sandbox is the same whether you opened it in game 1 or game 40, and `G.runGames` keeps climbing underneath. - `testLevel` 20 is the roster level `pickVariant` pretends to be at, so every body type can appear. - every barrel weapon on the rack, TOPPED BACK UP each frame (`stockTestRack`), so committing a burst — which spends the whole tin — leaves it available again a moment later. - a barrel every `testBarrelEvery` seconds, so the pickup itself is testable. **The third tap must not toggle `logShow`.** Taps one and two already toggled it twice, so it is back where it started; toggling again in the triple branch leaves the miss-log overlay switched on behind you every time you enter the mode. ## Verification recipe (the regression net) Drive the game deterministically via `window.T` with `javascript_tool`. The Browser-pane eval context reports `document.visibilityState === 'hidden'`, so RAF is paused; halt it anyway with `window.requestAnimationFrame = () => 0` and step with `T.step(1/60)`. `T.press('Space')` = tap. Clear the intro first: `document.getElementById('intro')?.remove()`. - **Two consecutive `render()` calls do NOT produce the same frame**, so "draw it, change one thing, draw again, diff the pixels" silently measures nothing. `render()` mutates camera state as a side effect (`aimCam += ((G.aim ? 1 : 0) - aimCam) * 0.15`), so the whole scene shifts slightly between grabs and the diff lights up everywhere — it reported a laser beam extending 16 px above where its own geometry could possibly reach. To check WHERE something lands, recompute the projection directly (`T.proj(...)`) and compare numbers; keep pixel-diffing for "did anything at all change". - **The module-refresh list MUST include the VERSIONED entry point.** `index.html` loads `src/main.js?v=N`, and `fetch('src/main.js', {cache:'reload'})` does not bust that URL — so main.js alone stays stale through a reload while every other module is fresh. Read the src off the page: `document.querySelector('script[type=module]').getAttribute('src')` and fetch that too. Symptom: a change in main.js appears to do nothing at all, repeatably. - **You MUST set `T.TUNE.autoAssist = 1.15` first** — the Browser pane reports `pointer: fine`, so the mobile assist is off and every ball reads as "never reachable". - Force splatter with `T.forceZombies()` then ~600 steps (in splatter it forces a HORDE instead). - `T.zombies`, `T.spawnZombie(x, z, 'a')`, `T.TUNE.zMax = 0` to stop spawns during tests. - Also exposed: `T.explode`, `T.splatPoint`, `T.startDuel`, `T.resolveInterlude`, `T.deliverTin`, `T.flickTarget`, `T.showModal`/`T.dismissModal`, `T.render`. **Benchmarks that must keep holding** (re-run after any physics/aim change): - **RETIRED 2026-08-08**: the ~91.7% CPU return rate. The opening CPU was deliberately softened (aiFlub 0.18 + aiFlubLoss rubber band, see that session log) — expect ~82% at 0–0, less as it drops games. The differential method below is still how to prove "no unintended regression". The old record, for the archaeology: CPU returns ~11/12 serves (91.7%). Recent 150-sample runs: 90.7%, 89.3%, 90.7%, 88.7% (±2.5%). Watch this — the last few sit under the bar rather than scattered around it. **The harness for this number was never written down and I could not reconstruct it.** A rig that counts "did the CPU get a racket on the served ball" scores 100/100, because after a flub the CPU gets another swing at the SAME ball (`aiSwingCool = 0.5`) and connects — 91.7% ≈ 1 − aiFlub, so the recorded number must count only the FIRST swing opportunity, or only returns that land in. Until someone writes the rig down, prove "no tennis regression" **differentially** instead: run your rig, `git stash` the change, reload the modules, run the identical rig, compare. Equal numbers on the same rig is the real claim; a bare percentage against this line is not. - 30 human-tapped points (tap when the incoming ball is 2.2–3.2 m away) = **50 hits / 0 whiffs**. This one DOES reproduce exactly — drive it off `G.missLog` (HIT vs MISS counts). - A 26-game state-machine soak with bombs/bursts/fire must finish with no exceptions, no stall, `mgT` back to 0, and gore/piles arrays bounded (gore caps at 40, piles at 30). **Two ways a soak lies to you**, both cost me a run: force-spawning zombies during `G.clearing` makes the hand-clear un-completable and reads as a 30-minute stall; and reviving the player after each `eaten` inflates gore past its cap, because the death push (`zombies.js`, the `eatenR` hit) is the one uncapped `gore.push` in the file — fine at one death per run, not at 260. Respect `clearing`, and either end the soak on death or set `TUNE.eatenR = 0` to make him untouchable. - **Never trust a single sample.** aiFlub is 9%; a 20/24 run is normal noise. Use 150. ## Asset pipeline **WHICH TOOL FOR WHICH JOB — read this before opening any `_*.py` (2026-08-14).** A fresh session reaches for `_regen.py` first, because it is the biggest file and the oldest name — and for most jobs it is the WRONG tool, superseded by the reference-sheet flow. Route by job: | The job | The tool | |---|---| | A whole opponent family (31 sprites, one character) | `_famroll.py` -> `_zsheet.py` — see "THE ZOMBIE FAMILY PIPELINE" below. **Never `_regen.py`**: nine separate rolls is nine chances for the character to drift, which is why the family pipeline exists. | | Rebuild the pose reference | `_famref.py` — composed from disk, no API call, gated. | | A PLAYER RUN CYCLE | `_vidroll.py` — a video model, not a still one. See "THE RUN CYCLES CAME OUT OF A VIDEO" below. `_runroll.py` still rolls the single frame you film FROM, and still serves the opponents' pairs; it cannot produce a gait. | | A stride twin (legs frozen) | `_stride.py`. | | THE SAME CHARACTER IN SOMEONE ELSE'S CLOTHES, every pose at once | `_frontroll.py` — famroll's trick pointed at the tennis poses: image 1 is the ten `o_*` frames composed from disk, image 2 is the body to dress them in. Two calls made the whole netplay family. Ten `_regen` batches would have been ten chances for the man to drift. | | Single-sheet remaster, boss roster, any non-family sprite | `_regen.py` + `_regen_batches.json` — the only jobs it is still for. | | Standalone art (logo, panels) | `_key.py`; coarse legacy sheets, `_slice.py`. | | The CROWD — the art itself | `_crowdroll.py` (three rolls: `seat`, then `clap` from it, then `mid` from that) -> `_crowdpass.py` (throws the drift away, keeps the arms, stacks the frames, grades the three courts, writes `src/crowd.js`). Every frame of all three courts comes out of one run; there is no such thing as editing half of it. | **And when installed sprites look WORSE than the `_refcheck/fam_*.png` sheet they came from, the suspect is the CUT, not the roll** — the sheets on disk are the evidence, and a re-cut is free (see "Only ink votes" below). Seven families shipped pixelated for two days because every fresh look started from "the generator drew it badly" instead of diffing sheet against sprite. **NEVER SAY "TRANSPARENT" TO THE MODEL — name the flat hex (re-learned the hard way 2026-08-14).** The model has no concept of transparency: the word summons a PAINTED CHECKERBOARD background, and "cuts out later" invites grey improvisations. Every background/bed instruction names ONE concrete flat colour ("ONE FLAT solid #FF00FF across the entire canvas") and never mentions cutting, keying or transparency. This rule already lived in `_regen.py`'s CANVAS block; a freehand one-off prompt bypassed it and cost three rolls — REUSE THE CANVAS BLOCK (import it from `_regen.py`) instead of re-writing background prose in scratch scripts. **STRING BEDS ARE MAGENTA ON PURPOSE, and the cutter must be told (2026-08-14).** Prompts ask for #FF00FF racket beds so they cut to transparency (the famroll rule). But `_fine.key` REPAINTS enclosed magenta pale grey — protection from the era when magenta beds were an accident — which is how the tennis opponents shipped holding solid white paddles. `key()`/`main()` take `bed=True` now (_runroll and the opponent-refresh cuts pass it; `_zsheet` has its own equivalent). New cut path for a sheet whose beds are meant to open: pass the flag, or the paddles come back. 1. `generate-image` skill (needs `OPENROUTER_API_KEY` in the gitignored repo `.env`; run with the scratchpad venv python — system python lacks `requests`). Passing the existing sheet as `--input` (edit mode) nails the style and is how every matching sheet here was made. 2. Ask for a **magenta background** and, for sheets, "at least 150px of empty magenta between frames" plus "the prop must stay inside its own frame". 3. `python3 assets/_slice.py sheet.png out1.png ...` — chroma-key on the corner colour, magenta de-fringe, column-gap segmentation, common-scale per sheet, `IDLE_TARGET_H=112`. `--largest` keeps only the main blob. 4. Standalone art (logo, popup panels) uses `assets/_key.py` instead: chroma-key + crop to the content bbox + optional downscale, no frame splitting, no forced height. 5. Register new names in `render.js`'s `SPR` preload array. 6. **New sprite not showing up? Bump `SPRV` in render.js.** Sprite loads go through `assets/.png?v=SPRV` for the same reason index.html versions main.js: python http.server sends no cache headers, so the browser caches sprite responses heuristically — **including 404s**. Add a name to the `SPR` list before its PNG exists, load the page once, and that browser treats the sprite as permanently missing while `drawZombie` silently falls back to another design. Bump SPRV alongside `main.js?v=N`. 7. **When a scripted `str.replace` edit is meant to rewire logic, ASSERT it matched.** A silent no-op replace left `drawZombie` on its old hardcoded boss names while everything else had moved to the BOSS_ART table, and the built-in fallback then hid the failure perfectly — all four boss designs rendered as design 1 and nothing errored. Cost a full debugging round. `drawImage` instrumentation is what finally settled it: ```js const o = CanvasRenderingContext2D.prototype.drawImage; CanvasRenderingContext2D.prototype.drawImage = function (img, ...a) { log(img.src); return o.call(this, img, ...a); }; ``` 8. **NEVER let two sprite names differ only by CASE.** macOS is case-insensitive: writing `assets/z_B1.png` for the chapter boss silently OVERWROTE the 'b' shambler's `assets/z_b1.png` (and `z_B2` ate `z_b2`), so a 3-metre boss started showing up in the shambler crowd and `git` reported the shambler frames as "modified" rather than reporting a new file. `ls` shows only the ORIGINAL lowercase name, which is why it is easy to miss. Boss frames are `z_boss*` now. Use all-lowercase, whole-word names. 9. **The prompt sets the ART RESOLUTION, and it is easy to set it wrong.** Sheets asked for "big chunky pixel blocks about 8 screen pixels across" and "flat blocks of colour, no shading" came back coarse — the generator obeys that literally, and Oliver reads it instantly as "waaaay lower resolution than z_mach2". Ask instead for HIGH resolution: *"build each figure out of roughly 90 to 110 pixels of height instead of the reference's 40"*, then enumerate what to spend it on — teeth, sunken eyes, individual fingers, seams, hems, folds, frayed edges, laced shoes, four or five shades per material with hatched shading. `assets/_regen.py` carries the working style block. 10. **`assets/_regen.py` + `_regen_batches.json` redraw a whole character end to end**: reference sheet from the sprites in the repo -> generate -> `_fine.py` -> colour-match onto what it replaces -> install, with three automatic retries. It REFUSES to install a bad sheet: wrong frame count, any figure touching a canvas edge (that is the bug that clipped z_f1), or a redraw that is not at least 1.25x finer by inner-edge density. `--list` shows the batches, `--all` runs them. 11. **`assets/_fine.py` is the slicer for detailed art.** `_slice.py` resamples NEAREST, which is right for chunky art and shreds fine art at a non-integer ratio. `_fine.py` snaps to the generator's own art-pixel grid, common-scales in ART pixels off a chosen `--anchor` frame (so a smash with the arm overhead stays legitimately taller), quantises back to the sheet's palette, and de-fringes the key colour twice — the generator does not always return a pure magenta and the halo survives as a rim on a green court. 12. **Two-row "style reference / redraw this" input does NOT work.** The model returns both rows and invents a background. Describe the target detail level in words instead. 12b. **`_regen.py` has THREE style blocks now, and picking the wrong one costs rolls.** The shared block used to be one constant that always said *"keep every pose, every colour EXACTLY as in the reference"* — correct for a remaster, and it fights every word of the subject for anything else. - default (`STYLE`): remastering a sprite that already exists. - `"newart": true` (`NEW_STYLE`): a NEW character drawn in the reference's style. Drops the fidelity clause and says explicitly not to copy the reference's body or outfit. - `"state": true` (`STATE_STYLE`): the SAME character in a new state — wounded, mid-throw. This is the one that is easy to get wrong, because `newart` looks right and isn't: it licenses a repaint, and it got one. Five wound sheets came back with living TAN skin and a redesigned shirt while the healthy frame stayed a grey-green corpse, and the coach's navy tracksuit came back mid-blue (torso RGB blue channel 111 -> 138). `STATE_STYLE` pins colours, clothing and build to the reference and states outright that he is a corpse, never pink or tan. **A brand-new sprite name must exist on disk before its batch runs** — `run()` measures `density()` of the file it is about to replace, and `colour_match` opens it. Seed each new name by copying a body of the right size and facing, and use `check: same`/`loose` so `colour_match` is skipped and the seed's palette is NOT inherited. The trap in that: a batch that silently fails leaves a perfect copy of another character behind, which renders as working art. Assert every new PNG actually differs from its seed before shipping (the scratchpad `check_seeds.py` does exactly this). 13. **`assets/_derive.py` is where a frame that must MATCH another frame is made.** Three of them live there now: `p_bounce2` (a per-column shear of `p_bounce` — the free arm bends, nothing else moves), `p_toss` (the drawn-in ball cut out, canvas height KEPT so drawChar keeps his scale) and the runner's hip registration. "Same drawing, one small difference" is the one request the generator cannot fill: it returns a different man and the swap reads as a flicker, which is the defect you were fixing. The bounce/toss steps are idempotent; the runner step is `--runner` and must run exactly once per generated set, because it mirrors and it pads. 14. **A new pose needs a STANDING frame in the same sheet, as frame 0, thrown away after.** A sprite's drawn size comes from its total height, so a sheet of nothing but arms-overhead poses has no honest scale to anchor on and the man comes out short. `_regen.py`'s `ref` field does this, but only when `ref_only` is unused — with `ref_only` set, list the throwaway as an extra sprite (`p_pull0`) and delete the PNG afterwards. 15. **NOTHING IN A SPRITE MAY BE PINK, MAGENTA, VIOLET OR PURPLE.** The chroma key does not merely match the corner colour, it deletes every pixel where red AND blue both beat green by 40 (`_key.py`: `min(r, b) > g + 40`, and `_fine.py` de-fringes the key twice). So a "vivid magenta drum" and a "magenta laser pistol with a violet coil" both came back as hollow outlines — 19% and 15% solid pixels against 50-56% for the icons drawn in other colours. Measure `alpha > 128` as a fraction of the box if a sprite looks thin; that is the whole diagnosis. **Pink can only ever be PROCEDURAL** — the weapon barrel is grey steel with yellow chevrons in its PNG, and every bit of its pink is the halo `drawBarrel` paints around it, which never goes near the keyer. State the forbidden colours in the prompt explicitly; "make it pink" is a request the pipeline cannot fill. Known generator quirks, all still true: - It sometimes ignores the magenta (harmless — the key uses the corner colour). - It draws near-identical animation frames unless you describe strongly contrasted poses. - **It adds props the A frames don't have** (a dragged pole on every brute B frame, a mop head). If the prop is a separate blob, keep the largest connected component; if it touches the body, cut on column density — a torso is dense, a dragged pole is a thin diagonal of ~7 px per column. - **If frames merge** (a handle bridging the gap) `_slice.py` reports "found 1 frames". Re-prompt for wider gaps first; if that fails, cut at the true column gaps plus the thinnest column inside the merged pair, then common-scale. - **Colour-match any regenerated frame** of an existing pair before shipping: fit a per-channel linear map onto the original's mean and sd, then verify with a mean-difference number. ## THE ART PIPELINE, corrected — what a day of chasing the generator taught (2026-08-12) A whole day went into sprite consistency and most of it was spent solving the problem in the wrong place. Read this before touching `assets/_regen.py`, `_fine.py` or `_master.py`; it will save the same day. ### The boundary that everything else follows | what it is good at | who does it | |---|---| | identity, pose, composition | the generator, **given reference images** | | exact colour values | the pipeline, **deterministically** | | layout accidents — clipped limbs, grids, merged frames, wrong frame count | the gates | Six gates were built in one day trying to make a generator do arithmetic. It cannot. The proof is unambiguous: the brute's serve sheet came back with his build, his garments and his DARK TONES pixel-identical to the reference — `(56,56,32)` in both — and his big pale shirt two shades brighter, `(200,200,184)` against `(176,176,152)`. The model was looking straight at the character. Every call re-renders, and re-rendering re-lights. **No prompt fixes that; `match_to_anchor()` does, in one pass, retroactively, on art already on disk.** ### A REFERENCE BEATS A DESCRIPTION. Every time, at every level. This is the single highest-value rule here, and every failure of the day was a violation of it: - The racket zombie's play sheet was generated from `[z_c1, o_idle1, o_run1, o_swing1]` — the character as an IMAGE, the poses as IMAGES — and landed first time. - The brute's three sheets each restyled from the master with the character in **words**, so each one re-derived "pale grey-green work shirt" from the same paragraph and the game got three different brutes. - Wound stages only started accumulating damage when stage 2 referenced stage 1's SHEET instead of being told in prose that the arm was still missing. The arm kept coming back because the reference picture had two arms in it, and a picture beats a paragraph — including when the picture is wrong. Corollary: every corrective sentence in a prompt is compensating for a reference you did not supply. When a prompt grows a third "do NOT" clause, add an image instead. ### CORRECT, THEN CHECK `match_to_anchor()` runs before the palette gate, so the gate is a safety net for what arithmetic could not fix rather than a tripwire for what it can. It pairs each of a frame's dominant colours with its nearest anchor colour, fits one gain+offset per channel, and applies it to every pixel. Pairs further apart than `NEW_COLOUR` are dropped from the FIT — those are colours the anchor genuinely has not got, i.e. blood on a fresh wound — but they still RECEIVE the transform, so a wound stays the right red relative to the body it is on. ### CALIBRATE BEFORE YOU GATE The discipline that was missing all day. Every threshold in `_regen.py` was picked by measuring frames known to be right against drifts that were visibly wrong, and rejecting the formulations that overlapped. Three formulations were tried for the palette gate; two overlapped and were thrown away. Do not add a gate without printing that table first. The gates, and the numbers they were calibrated on: - **stride** — is a walk cycle ONE DRAWING WITH THE LEGS MOVED? Height ratio, upper-body delta, leg delta on a feet-aligned overlay. Good pairs: brute lurch `1.00/0.000/0.638`, shambler `1.00/0.070/0.520`. Failures sat at upper 0.47-0.85 (two unrelated illustrations) or legs 0.09 (the same picture twice). Opt-in per batch, because a four-frame RUN legitimately swings the arms. - **palette** — are the big colour areas the anchor's colours? TOP SIX by area, proportions ignored so a pose change is invisible, and blood is never in the top six so a fresh wound cannot look like drift. Right frames 0.0-8.5, visible drifts 12.0-15.9, threshold 10. **This one number replaced four hand-picked features** (`kit`'s shirt redness, shorts brightness, shorts hue, near-white fraction), every one of which a different character broke — the brute's shirt has no red in it at all, two of the four move with POSE, and none of them ever measured SKIN. - **changed** — did the wound actually happen? A copy scores 0.02-0.05, an amputation 0.12-0.38, a decapitation 0.27-0.75. Measured whole-body OR above-the-hip, whichever moved more, because an arm is a body-relative amount of silhouette: 12-38% of a lean man, 9% of a brute. - **no grids** — asked of each cut PIECE, not of the sheet. A body is vertically continuous, so a full-width empty band inside one piece means the cut caught two of them. - plus the older ones: frame count, magenta background, nothing touching a canvas edge, inner-edge density. ### Traps, each of which shipped something silently before it was caught - **A rescue that turns a loud failure into a quiet one is worse than no rescue.** `_fine.py` gained a valley-cut fallback so a sheet whose figures touch is not thrown away — and it happily sliced a 2x2 GRID into four frames of a torso above someone else's boots. It installed cleanly and read in the log exactly like a success. - **A failed stage must block the stages drawn from it.** The brute's headless sheet installed off an arm-off stage that never landed: three perfect frames of a two-armed corpse. Running was worse than failing, because it SUCCEEDED. `_regen.py` now skips a batch whose `ref_only` is in a failed batch's outputs. - **A placeholder is a stand-in for art that does not exist yet, and nothing else.** Re-running `--variant` to rewrite a spec re-seeded EVERY placeholder from the master and silently replaced a finished brute's serve and fall sheets with copies of the lean man they were posed from. Three passes were then spent regenerating sheets that already existed. `variant()` never overwrites now. - **The detail bar RATCHETS against the file being replaced**, so a chained stage measured against a copy of the master has to out-detail art two steps better than the stage it follows. Chained placeholders are seeded at RUN time, from whatever the reference has become by then. - **`_fine.py` keyed every magenta pixel, not just the background** — and the generator paints racket strings in the backdrop colour more often than not, so sheet after sheet installed a body holding an empty hoop. Background is the region connected to the border; magenta left INSIDE a body is repainted pale grey. (`Image.fromarray` images are READ-ONLY and PIL's `floodfill` swallows the write error, so the first version of that fix was a silent no-op.) - **Put the background rule FIRST in the prompt.** Six rejections in eleven rolls were the generator painting the zombies on a tennis court, because the magenta instruction sat behind three hundred words of "keep everything exactly as the reference". ### Still open - **`changed` does not know WHAT changed.** It passed a "headless" brute that still had its head, because something moved. It measures that a frame differs from its reference, not that it differs in the right way, and no cheap measure of "the head is gone" has been found. - **A variant is reliable for its play sheet and a gamble for its wound stages.** The brute's healthy eleven are good; its arm-off fall frames are standing figures of the wrong build and its headless row is not headless. `--variant ` and `_master.py`'s master sheets are the right shape; the wound half needs the reference discipline applied harder than prose currently applies it. - The brute is PARKED, not deleted: `config.ZFAMS` is one entry away from making it live, its 31 frames are on disk, and its nine batches are in `_regen_batches.json`. **SUPERSEDED 2026-08-12 — see the next section.** Those 31 frames were not art: `--verify` found 27 of them dead. The brute is now live (`ZFAMS` has `'br'`, the KEEPER wears it) off ONE sheet. ## THE CROWD, ROLLED AGAIN — AND ONE SPECTATOR AT A TIME (2026-08-21, same day) Oliver on the pass below: *"the still look a little rough.. and now the new lighter areas read as rather sloppy artwork. please regenerate the crowd art and make it more detailed. also the animation also looks pretty artificial. the flashes from the fotographs are too rare and the the whole thing somehow does not translate well to the other courts. also when the opponent makes a point there is an aww-sample (which has an applaus trail) so the crowd should still clap but more subtle."* All five, and the first one retracts a claim the section below made: **there IS a key** — the roll scripts read `OPENROUTER_API_KEY` from the repo's own `.env`, not from the environment, so "no API key in the environment" was a wrong conclusion, and painting detail onto an old sheet with numpy was never the right tool. It is deleted. The art is rolled. - **THE ART IS A NEW ROLL** (`assets/_crowdroll.py`, two calls). Individually drawn spectators — hair, hats, sunglasses, drinks, cameras, small flags, plain banners, empty seats, stairways, a few already on their feet, and three zombies sitting in the middle of the crowd behaving like furniture. The prompt states the art resolution in FIGURE HEIGHTS ("about 90 pixels of height instead of the reference's 40"), which is the one lesson the sprite rolls paid for: the generator picks its own canvas, so a pixel count means nothing to it and a proportion does. The old sheet goes in as image 1 for LAYOUT ONLY — same camera, same tiers, same railing. - **ONE ROLL, THREE COURTS.** Each court is a per-channel histogram match onto its own old sheet, so clay is still terracotta and the night court is still night, but the crowd underneath is one drawing. The same LUT grades both frames of a pair, which is what keeps them a pair. - **THE ANIMATION IS PER SPECTATOR.** The clap roll drifts by 29% of its pixels; `_crowdpass.py` keeps only the blobs that were empty stand and became a person — the raised arms, 1.2% of the sheet — and writes a box around each into `src/crowd.js`. render.js draws ONE BOX AT A TIME, so a single spectator's arms go up. The 16-column strip swap it replaced moved a seventeenth of the crowd as a block, which is what read as machinery ("the animation also looks pretty artificial"). Boxes that overlap are merged into one entry: two neighbours who always rise together are one person as far as the animation is concerned. - **THE SIZE OF THE MOMENT IS NOW A HEAD COUNT.** `crowd.pop` decides HOW MANY of the 93 join in (each on their own period, staggered by a stable per-person draw), so the same code does the whole range: a groan is a dozen people, a game is half the stand, and past 0.75 it becomes the wave — which is now a front travelling person by person rather than column by column. - **THE AWW CLAPS.** `tennis-aww` is a groan that settles into scattered applause, so a stand that sits through it contradicts the sound. `aww()` now sets a small `crowd.pop` itself — in audio.js, because it is the SAMPLE that makes this true and every current and future caller should get it. - **MORE PHOTOGRAPHERS.** `TUNE.flashEvery` 5.0 -> 1.9. At a five-second mean, jittered, spread over a stand this wide, Oliver never saw one. ### The clapping was invisible, and the stand was too loud (2026-08-21, same day) *"when clapping I barely see any movement at all now.. also all of the art could be colored a little more dim as it is perspective wise farther in the background and should not steal too much attention."* Then: *"can't we make an animation sprite sheet of the new crowd?"* — which is the right question and the answer to the first half of the first one. - **A THIRD ROLL, and the sheets became animation sheets.** `_crowdroll.py mid` takes the arms-up frame as its reference and asks for the same standing people with their hands brought together in front of their faces. It drifts harder than the first (53% of pixels) and it does not matter: the same filter keeps only stand-turned-person. 100 arm blobs in pose 1, 74 in pose 2, 134 spectators once merged, **28 of them with both poses** — those 28 cycle between two raised poses, which is a clap; the rest alternate with the seated frame, which is arms going up and down. Either way the stand is now MOVING rather than holding, and that is what the eye catches at 8x6 logical px. Measured: 2-3% of the stand band changes every 90 ms during a won game, against near zero before. - **One file per court instead of two.** Three frames stacked vertically, graded as one image so the frames cannot drift apart in colour, and `x_crowd2`/`x_crowd_c2`/`x_crowd_h2` are deleted. - **`TUNE.standBack` 0.22** — the stand pushed back, applied in the one place both the cached backdrop and the live raisers read. It is a DEFAULT, not a floor: stacking it on top of the two courts that already dim themselves buried them, so `court.dim` replaces it, centre court claims a 0.06 of its own, the night court came down 0.62 -> 0.5, and centre court's sheet is graded off a lifted target so there is something under the dim to see. ## THE STAND STOPS WOBBLING, AND STARTS BEING A CROWD (2026-08-21) *"the crowd spritesheet could use some love (resolution and movement wise)... also the crowd wobbles with a client side animation right now.. that feels odd. also add with a little bit of humor"* — and, mid-build: *"the crowd could be more detailed (and I don't [mean] the technical resolution of the image)"*. **The wobble was never the animation.** `x_crowd2` was a RE-ROLL of `x_crowd`, not a pose of it: 13-16% of its pixels differed, spread over faces, torsos, seat backs and the railing. Swapping 16 columns between them at 4.5 Hz therefore re-hashed a strip of crowd twice a second. Every knob you could turn on the animation was the wrong knob. `assets/_crowdpass.py` rebuilds the pair instead, and establishes the invariant in "Architecture & invariants". Three things fall out of the fact that **the three courts are one composition in three palettes, pixel-aligned**: - the art pass finds its people ONCE (on the clay sheet, the cleanest — grass carries the brightening curve's noise) and applies the same masks to all three, so the three crowds stay the same people; - a raise that exists in only ONE court's roll is **transplanted** into the other two through a colour map learnt from the aligned seated pair (`colour_map`). Per court the honest raises number about fifteen; pooled, 44 people can get to their feet. Eight of them are baked STANDING into both frames; - everything the art pass paints is excluded from the clap set, so no painted pixel can ever flicker. That ordering is load-bearing — the clap set is chosen FIRST and reserved, because a crowd this heavily painted leaves nothing that is allowed to move if you paint first. **RESOLUTION IS NOT THE PROBLEM AND ADDING PIXELS WOULD NOT HELP.** Measured: the sheet is 1408x768 and the stand band is ~1080x784 device px on a 3x phone, i.e. already ~1:1 vertically and 0.77x horizontally (in landscape it is 1.36x wide by 0.54x tall — the sheet is stretched to whatever band exists, so spectators are anamorphic, differently per orientation; see OPEN). At that size a spectator is ~8 logical px, so "more detailed" can only mean **chroma and silhouette**, which is what the pass adds: 55 shirts and 44 heads of hair in colours the court does not own, 28 of those as caps, 4 flags on poles above the heads, 8 emptied seats so no row ends in a straight line of identical heads, and 8 people permanently standing. **HUMOUR, and it costs nothing:** three spectators are already TURNED (grey-green skin, mid-row, nobody around them reacts) — and because a painted pixel is never in the clap set, they are also the only three in the stand who never applaud. It is a hint at the parked stands-turn-into-zombies step, not that step. **MOVEMENT — one draw path, three behaviours** (`drawClapColumns`): - **applause**: every column on its own phase, 3.5 Hz — a scatter of people rising and sitting; - **THE WAVE**: a band ~2.5 columns wide crossing the stand every 1.4 s, and only when `crowd.pop >= 0.75` — the set, the match, and the ones the umpire calls unbelievable. A game won is `cheer(2.4, 0.6)` and a wave on every held serve is a wave nobody looks up for; - **the FIDGET**: with nobody cheering, ONE column stands for a third of every `TUNE.fidgetEvery` (4.5 s). Between points the stand used to be a photograph. The column walks `slot * 5 + 3` — five is coprime with 16 so it visits every column, where the first cut (`* 7919`, i.e. `* 15`) moved one seat sideways each time and read as a march. **PAPARAZZI SCALE WITH THE MOMENT** (Oliver: "increase paparazzi burst during bigger events"). `cheer(seconds, vol)` now records `crowd.pop` (the size of the moment, 0..1 — vol was already exactly that at every call site) and queues `crowd.burst` shutters proportional to it; `tickFlashes` drains that queue at a fifth of the normal gap, and outside a burst the rate rides `pop` too. **`pop` is held at the loudest while an applause is live** — a won rally landing inside a match-point ovation must not demote the stand to a polite clap, and `cheer()` is called from a dozen places that overlap. That is the one new state-machine rule here, and it has a test. **SUPERSEDED THE SAME DAY — see the section above.** Two things in this one were wrong. The paint pass ("55 shirts and 44 heads of hair...") is gone: Oliver, *"the new lighter areas read as rather sloppy artwork"*, and he is right — recolouring blobs of somebody else's drawing is not drawing. And "no API key in the environment" was a wrong conclusion that made the paint pass look necessary: the key is in the repo's `.env`, which is where the roll scripts have always read it from. What survives from this session is the diagnosis (the frame pair, not the animation), `crowd.pop`, the paparazzi scaling and the fidget. ## THE SHELL — CAPACITOR, HAPTICS, PORTRAIT, SWIPE-BACK (2026-08-20) The first three bullets of LATER item 2 ("What the shell buys GAMEPLAY-wise") are built. **The web build is unchanged and stays the primary surface** — every line below degrades to a no-op in a browser, and `splatter-tennis.wuwi.one` keeps deploying off the same repo on every push. **The zero-dependency rule bends exactly once and exactly here.** `package.json`, `node_modules/` and `ios/` exist for the SHELL. **No JS from any package ships.** `src/haptics.js` calls the bridge the shell injects — `Capacitor.nativePromise('Haptics', 'impact', { style })` — never `import ... from '@capacitor/haptics'`. There is no bundler in this project, so a bare specifier inside a `