# HANDOFF — Splatter Tennis **This file is the only planning document.** `PLAN.md` and `POLISH-PLAN.md` are deleted — everything they specified is shipped, and what still matters from them is recorded below. For the long history, `~/.claude/projects/.../memory/splatter-tennis-history.md` is auto-loaded; for the detail of any individual change, read the git log. Last updated 2026-08-08 (testgroup round: popup policy, ghost hand, barrel economy). **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`, `*.js`, `server.py`, `assets/`). - **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 (all in repo root) `config.js` TUNE (live T panel) + structural constants + `UI` palette · `state.js` the `G` object + P/O/ball/score + `setupPoint`/`sendBoy` · `gameplay.js` ballistics/swings/serve/rules/AI/`update()` + transition + `splatPoint` + ball-boy routines · `zombies.js` zombie/gore/particle state + `updateZombies` · `render.js` camera/palette-lerp/sprites/all drawing · `input.js` keys/touch/flick · `audio.js` sfx + recorded umpire (`assets/audio/*`, macOS `say -v Daniel`) · `panel.js` the T panel · `main.js` boot, loop, scoreboard test-mode taps, `window.T` test surface (the umpire-tap cheat is removed — the testteam found it; `T.forceZombies()` is the console replacement). THREE runtime-only import cycles (documented in headers): input↔gameplay, zombies↔gameplay, and state↔zombies (`sendBoy` reads `gore` to decide whether a mopping round is worth walking out for). All three are safe for the same reason: the imported binding is only ever touched inside a function at runtime, never during module evaluation. ## 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. 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 swish cooldown scales with how swarmed you are** (`meleeCooldown()` in gameplay.js): `meleeCool / (1 + (near-1) * meleeCrowd)`, floored at `meleeCoolMin`, counting bodies within `meleeCrowdR`. 1 body 1.00 s, 4 → 0.47, 8 → 0.27, 14+ → 0.22. Oliver died on every game-20 horde because the wave grows with `hordeGrow` while the panic button was a flat 1 s; he chose scaling the melee over shrinking the horde. **Measure the crowd BEFORE calling `meleeSwipe`** — it kills everything in reach, so counting after counts the survivors and the cooldown barely moves. - **The canvas is deliberately FULL-BLEED under the notch and home indicator; the HUD is what respects the safe area.** `viewport-fit=cover` is set, and the stands running to the screen edges look right — but an iPhone's clock was landing directly on the scoreboard. A hidden `#safe` probe div carries `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()` — 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). - **Crowd applause is a real two-frame swap** (`x_crowd` seated / `x_crowd2` arms up), per column on its own phase, 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 board art is 99.9% opaque; nothing was ever showing through it). **If you touch one crowd frame you must apply the identical curve to the other**, or the swap starts flashing brightness. `assets/_sheet_crowd1.png` is the pre-brightening original. ## Settled feel decisions — do not regress silently - **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 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 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. **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 `main.js?v=N`, and `fetch('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 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. ## Session log: Oliver's playtest of the barrel weapons (2026-08-03, same day) Everything below is a fix to what shipped that morning. All four notes were real defects, which is now seven playtests in a row where "this feels odd" was a bug and not taste. **"The bigger guys were way too easy to kill at the beginning."** It was a bug, and an old one. The ball-vs-body chain handled boss, `g`/`m`, `s`, `f`, `a`/`b` — and **everything not named in it fell through to "splat-quality kills outright"**. So the STEWARD has been dying to one serve since the day he shipped with `stewardHp: 5` and a comment calling it "a brute's worth of hp", and the ball boy, bouncer and roller man inherited that the moment they existed. Pellets and melee always read `hp`; only the BALL skipped it. It is one generic `hpMax > 1` branch now — hp is the only thing that says how many hits a body takes — and measured after: shambler 2, steward 5, brute 5, ball boy 3, bouncer 5, roller man 7, each exactly its hp. **Anything that adds a body type must NOT need a new branch here; if you find yourself writing one, that is the bug coming back.** **"I have seen all of them by round 10."** The new three moved 3/8/15 -> **5/12/20**. The original four keep their gates exactly: the curve through ~game 12 was already tuned and the calibration rule says not to touch it. Their hp came down with the fix landing (9 was a fine number while nobody felt it and a long grind once they did). **"The laser fired only when I tapped, that was confusing."** The whole `kind: 'shot'` idea is gone. A barrel weapon is now exactly one of two things, and this is the rule to hold: **either it is the normal ball gesture made spectacular, or it fires itself for a while.** Nothing waits for a tap. - **laser** is a `serve` now: pull, aim, release. **The RAY is the weapon**, so there is no ball anywhere in it — the preview is the WEDGE itself (`drawBeamWedge` at low alpha, not a ballistic band with a target ring), the tap path has its own `tryLaser` so it never tosses a ball it is not going to hit, and `fireBeam` kills everything inside the cone (`beamW0` 0.35 m at the racket to `beamW1` 4.2 m at the far end). Measured: 8 of 11 bodies in one release. Three things Oliver corrected on the second look, all of them "it must read as light he fired": **it leaves the RACKET** (`dragContact()`, y 2.2, settling onto the court as it goes out — drawn flat from his feet it read as a shape lying on the grass); **the release ERASES it along the path** (the plane is whole at the flash and is wiped away from the racket outward behind a bright front, so the shot reads as energy travelling down the lane rather than a decal fading where it lay); and **the edges are hairlines** — at 2.5 px they were heavier than anything else on the court and read as a drawn outline instead of as light. *Gotcha for testing:* `TUNE.pullMin` is 18 **PIXELS**, and `aim.fy` is NEGATIVE for the down-pull. A synthetic `{fx, fy, mag: 0.8}` is silently below the threshold and `previewServe` returns null, which looks exactly like the preview being broken. - **beacon** and **rocket** are `burst` and **LOBBED** — `arc` on the WEAPONS row turns a flat pellet into a thrown one (solved onto the nearest body ahead), and landing is itself a hit, so a rocket that misses still goes off where it lands. The beacon changes colour every ball and drags that colour behind it. - **flamer** got "way more pronounced": 30 rounds/s, a ~10 m cone, fat glowing rounds with embers. **The pale blue washed out to grey over the lawn** — a beam colour has to be saturated (`#3f74ff`), which is the same lesson as the barrel's halo, from the opposite direction. **"I got the laser almost every time."** A flat 1-in-4 over four items does not feel uniform. It is weighted and gated now (`BARREL_WEAPONS` carries `weight` and `from`), never repeats twice running, and the beacon is what you meet first. Measured: laser 16-28% depending on the game, rocket unlocked at game 12 so it is genuinely a late find. **The barrel's pink is procedural and can be as pink as it likes** — Oliver asked whether it could still have a pink halo given the key eats magenta out of PNGs, and yes: `drawBarrel` paints it at draw time and never goes near the keyer. Three rings plus a pool on the lawn now, because one flat disc read as a smudge. ## Session log: a rack you fill yourself, and barrels that fill it (2026-08-03) The second half of the same ask. **The rack no longer has fixed slots.** It used to be five hardcoded positions where the index WAS the weapon — `SLOTS`, `slotStock`, `WDESC`, `drawRack`'s three parallel arrays, `deliverTin`'s `w + 1`, and bare `wsel === 1|2|3` / `G.weapon === 4` tests in five files. Now `config.WEAPONS` is the single table, `G.rack` holds up to four `{id, n}` in the order you GATHERED them, and slot 0 is BALL. Read it through `slotId` / `slotStock` / `slotEntry` and nothing else needs to know how it is stored. **A fifth weapon is always allowed** (Oliver's rule): it rides slot 0 as `G.temp` until spent, then slot 0 goes back to being the infinite ball. A barrel arms on the spot and is spent quickly, so refusing it would mean a barrel you shot did nothing. **A spent weapon LEAVES its slot** — with gathered slots, an exhausted weapon sitting in one is a quarter of the rack you can never pick up into. (The old rack kept empty slots dimmed, which was right when a slot meant "BOMB goes here".) **Four new weapons, and they are all the one pellet loop.** `firePellet(w)` stamps the WEAPONS row onto the round — `r`, `dmg`, `hp`, `pierce`, `boom`, `ignite`, `col`, `speed`, `life` — so the beacon cannon, flamethrower, laser and rocket needed no new array and no new update. The `break` after the first body was the only thing making a pellet single-target. Every one of them goes through the same `isBoss` branch, which is why none can silently no-op on a boss the way three of five once did. Measured: gun/beacon/laser/rocket reach 16 m+, the flamer is a deliberate ~6 m cone that ignites. **`G.mgT` stayed the mode timer for all of them**, which is why the eighteen `!(G.mgT > 0)` guards across five files did not have to change. `kind:'burst'` auto-fires at its own rate for `len` seconds; `kind:'shot'` (laser 10, rocket 3) holds the same braced stance and fires one per TAP. **The barrels** are the ball machine's shape reused: a rolling, shootable non-body. Three registrations are silent if forgotten and all three are done — `blocksClear` (a barrel must NOT gate the hand-clear; it is optional loot), the contact-kill guard (furniture cannot eat you), and `hitR`/`GORE_MUL`/`zombieH`. It scores nothing. **The pink is procedural, and it has to be** — see pipeline note 15. A magenta drum and a magenta laser pistol were both eaten by the chroma key. `TUNE.tinTriples` 3 -> 10, as asked. ## Session log: the roster grows — moving bosses, six of them, three new bodies (2026-08-03) Oliver asked for five things. This is the first half: **enemies first, weapons second**, his call, so the arsenal could be tuned against the harder game rather than the other way round. **Bosses move now, and the style is data.** A boss previously matched NO branch in the variant chain and fell through to the crowd's aimless ±0.15 m/s wobble — "the boss AI owns its x" was a comment describing code that did not exist. Six styles, one per design: `pace` (groundskeeper, and the coach at 1.9x), `shadow` (line judge — paces a band centred on YOU), `surge` (chair umpire — rolls up and down the whole depth band), `charge` (physio), `bounce` (mascot). Measured over a simulated minute each: 50–136 m travelled, none static. **Three earlier drafts of these styles failed the same way and it is worth knowing why: they were written as "walk toward the player's x", which in splatter means arrive in four seconds and stand still for the rest of the fight, because the player is rooted in `pretoss` and never moves.** Measure `xSpan` over 3600 steps before believing any movement change. **Three new bosses**: THE HEAD COACH (rapid 5-shot drill, 0.14 s apart), THE PHYSIO (charges — it withdraws LESS than it charges, so it is a couple of metres nearer after each rush and reaches you at about 38 s, which is the fight's clock), THE SPONSOR MASCOT (a `gap: 0` volley — the whole wall of five leaves in ONE frame, so it is dodged, not parried; the salvo loop is a `while` for exactly this). Harder to kill: `bossHp` 16 -> 20, `bossHpPerChapter` 9 -> 10, plus per-design `hpMul`. **Chapter 6 is now 91 hp — the number most likely to want tuning after a real playtest.** **Three new bodies**: ball boy `'e'` (3 hp, 2 skins, from game 3), bouncer `'h'` (6/3, game 8), roller man `'k'` (9/4, game 15). Named "roller man" and not "groundsman" so he cannot be confused with the GROUNDSKEEPER boss. **One real bug found, and it was pre-existing.** `killPlayer()` empties `shots[]` while the shots loop is mid-iteration, so every remaining index reads `undefined` and the frame throws. The mascot's five-at-once volley makes it easy to hit; the shipped 3-shot volley could always have hit it. Fixed with the same `if (!s) continue` guard the body loop above it already carries, and for the same reason. **Verification that actually caught things** (all via `window.T`, RAF halted): - `drawImage` instrumentation per design: all six bosses and all three new bodies draw their OWN frames, both `…w`/`…wb` stride twins included. Nothing silently falls back to design 1. - Differential check on the refactor: the brute still draws `z_gw/gw2/gw3/gw4` at hp 4/3/2/1 — the new shared `woundStage` is byte-identical to the array it replaced. - Foot-gap spread: ball boy 76% (the shipped shambler is 36%, the brute 39%). Boss stride-pair difference 34–54%, inside the shipped range of 30–83%. - 26-game soak: no exceptions, no stall, `mgT` back to 0, gore capped 40, piles capped 30, and `e`/`h`/`k`/`B` all appeared in it. ## Session log: court types (2026-08-01) Three courts, picked on a second start-screen stage. A court is pure DATA — `COURTS` in config.js — so adding one is a table row plus four PNGs, never a change to `render()`. - **Each court carries TWO palettes, not one.** Every court still rots when the horde arrives, so the row holds a `tennis` palette and a drained `splatter` one and `palette()` lerps between them exactly as before. A single palette plus a global rot filter was the tempting shortcut and it is wrong: what "drained" means is different on orange clay than on green lawn. - **Mow stripes are a flag, not a colour.** `court.stripes`. Recolouring the lawn to terracotta and leaving the bands on still read as grass — the banding is the grass tell, not the hue. - **Physics is two numbers copied into TUNE on selection** (`selectCourt` in state.js). Grass skids low and fast (0.50/0.84), clay sits up and drags (0.66/0.70), hard is the old baseline (0.58/0.78). Doing it this way means the three bounce call sites in gameplay.js never learn that courts exist, and the T panel still edits the live values. - **A court's clap frame must come from its own sheet.** `x_crowd`/`x_crowd2` are a matched pair — same people, arms up. Pairing court A's seated sheet with court B's clap sheet cross-fades between two different crowds, which is the flicker class this codebase keeps re-learning. The clay/hard clap sheets were generated FROM their own seated sheet ("same image, arms raised"), not from `x_crowd2`, for exactly this reason. - **Ad-strip aspect is load-bearing.** `render()` draws the sheet at a fixed `AD_H` of 14 px and derives tile width from the sheet's own aspect, so a strip that comes back 6:1 instead of 9:1 renders its lettering at half size and goes unreadable. The first clay roll was 6.34:1 and was thrown away; re-rolling with `x_ads_h.png` as the input (already 9.27:1) fixed it. **Check the aspect of a regenerated ad strip before installing it.** - **The picker is DOM, not canvas** — a second panel inside the existing `#intro` overlay, rows built from `COURTS` in main.js. Same Silkscreen card, no new `G.mode`, no canvas UI code. Last pick is remembered in `localStorage` and comes up pre-highlighted; keys 1..N pick, Enter takes the highlighted one. Not done: clay ball-marks (the `gore` decal loop already draws flat ground ellipses, so a pale scuff per bounce is one call), per-court bounce SOUND, per-court crowd density. **Menu theme** (`background-music.wav`, `menuMusic`/`menuDuck` in audio.js). Started at BOOT so the title screen has music, ducked out when a court is picked, faded back in at `EATEN_SUMMARY` — the death summary is the end menu, and the zombie bed ducks out as it comes in. Two things this cost: - `loopBed` now calls `audio()` BEFORE its already-started early return. The theme starts before any gesture exists, so on a phone it plays into a context the autoplay policy holds suspended; the call that wakes it is a second `menuMusic()` from the first tap, and that used to return at the `beds.has` guard without ever reaching the `AC.resume()` inside `audio()`. - `fadeBed` records a duck for a bed that is STILL LOADING (`ducked`), and `loopBed` starts that bed's gain at 0. Without it, a multi-megabyte file ducked mid-fetch comes up at full level several seconds later, over the match. **The overlay is a four-stage machine**, not a screen per DOM node: `#intro[data-stage]` swaps `splash -> {setup, stats, ranks}` while the backdrop and card frame stay mounted, because a screen that unmounts the overlay flashes the live court through between menus. **The title screen IS the menu** — a separate menu stage behind it was the same logo on the same backdrop with two buttons, and read as the title screen failing to advance. Court tiles SELECT only; `SERVE UP` commits. **THE TUTORIAL IS GONE** (was `TUT` in main.js, eight pages of Marvin). What it explained is already taught in-game by the first-time-only modals, and its stage is the stats screen now. Deleted with it: `tut_*.png`, `marvin1-4.png`, `btn_tut.png`, `btn_start.png`, and the `marvin` regen batch. `x_howto.png` and `x_cross.png` LOOK tutorial-ish and are not — they are in-game modal art. **Every menu screen is three bands**: something pinned to the top, something pinned to the bottom, and the slack in between. `.card` is a full-height flex column, `.foot` uses `margin-top:auto` (drop to the bottom of a short screen) plus `position:sticky` (stay there on a long one), and `#intro` is the scrollport those stickies resolve against. `align-content` is **`safe center`**, never `place-items: center` — plain centring on a grid whose item overflows pushes the item's top out of the scrollport where nothing can reach it. Two registers, unchanged: **Bangers shouts** (screen titles, the crimson primary button), **Silkscreen states** (every number, every label). **The court cards' SPEED and BOUNCE meters are derived from `COURTS[].bounceXZ` / `.bounceY`**, not from hand-written ratings — so a physics change moves the bars and the screen can never advertise a court that plays differently from what it says. **DEATH NO LONGER RELOADS THE PAGE.** `location.reload()` was an honest full reset and for a long time the right call — but it also tore down the AudioContext, so the menu theme restarted from silence every time you died, when it is supposed to carry straight on from the death summary into the title screen. `gameplay.restartRun()` replaces it: - `state.resetState()` restores `G`, `P`, `O`, `ball` and `score` from a `structuredClone` taken at module load. Everything in those bags is plain data, so the clone is the whole job — nothing has to enumerate fields, and a field added later is covered for free. - The two modules with timers of their own put them back themselves: `zombies.resetZombies()` (every array plus `spawnT`/`moanT`/`mgTick`) and `input.resetInput()` — a key still held or a half-finished drag would otherwise be inherited by the next run's first frame. - The overlay is **hidden, not removed**, so it can come straight back. `#intro[hidden]` needs its own rule because the `display: grid` on `#intro` would otherwise beat the hidden attribute. **If you add module-level mutable state anywhere, it belongs in one of those reset functions.** **`stats.js` is the only thing that remembers anything.** One localStorage key, `splatterRuns`: an array of per-run records (`{t, g, s, d, k, mk, dead, z, w}`), newest last, capped at 300. ALL TIME / LAST MATCH / 7 DAYS are three filters over that one array, not three stored counters — a fourth period is a filter, not a migration. It replaced `splatterBest`, which it migrates on first read. - **The live run is module-local, not a field on `G`.** `G` is deep-cloned into `PRISTINE` at load and restored wholesale by `restartRun()`; a counter living there would be reset by machinery that knows nothing about stats. - **`killZombie` takes an index, not a cause.** Eleven call sites reach it, so instead of threading a weapon id through all of them (and through `wound()`, and through `splatter()`), every damage loop in zombies.js stamps a module-level `CAUSE` before it can kill. Safe because a frame is one synchronous pass. **The trap**: a kill path that fires OUTSIDE a stamped loop inherits a stale cause — the flame ball's fly-past `ignite()` did exactly that and filed 'fire' kills under 'ball'. Burning bodies carry `zb.burnBy` for the same reason: they die long after the thing that lit them. - **`endRun` needs an explicit `banked` flag, not an "is the bag empty" inference.** The games count lives on `G` and survives the flush, so a second call sails past a nothing-happened guard and writes a duplicate with zero kills. `pagehide` really does fire more than once (bfcache dismiss and restore), which is how this was caught. `tally()` clears the flag, so a fresh run re-arms itself with no reset call for anyone to forget. - `T.stats.check()` is the runnable check: it asserts the period filter and the aggregation against a fixture, and `read()` takes an injected array precisely so it never touches a real save. **THE DEADLOCK WATCHDOG** (`watchdog()` in gameplay.js) exists because a run that hard-locks costs the player everything, and this file has form: `mgT` was once left above zero by a beat that moved the phase underneath it, and `mgT > 0` blocks serving. It does NOT time how long the player has been blocked — a horde blocks you for fifteen honest seconds. It watches whether the CLOCK that should release you is moving: every legitimate wait (horde, burst, changeover, duel intro, an 'over' beat) counts down, so a frozen signature for `TUNE.stuckT` seconds means the state is dead. It logs the frozen signature into the miss log (scoreboard tap) before letting go — **a lock that unsticks itself and leaves no evidence is worse than one that does not.** Soaked five game-minutes to game 23 through a horde: zero firings. - **Anything new that blocks serving must either tick a clock or be added to `watchdog`'s release.** - The matching root-cause fix is in input.js: `visibilitychange`/`blur`/`pagehide` drop the in-flight gesture. `stick` and `G.aim` are only cleared by the touchend for the matching identifier, and iOS does not guarantee that event when you switch apps mid-drag — a held `G.aim` then blocks the uncaught-toss retoss (`!G.aim`) forever. **A won duel must always pay.** `deliverTin` walks the four-tin ladder, and when every rung is at `wCap` — which is the state from about game 20 — it now falls through to the **barrel weapons** (unlocked ones only, so no rocket in game three). Before that it printed RACK FULL and handed over nothing, which is the one thing the duel exists for. `RACK FULL` now means literally everything is capped. **The tin gets a CARD, not a flash.** `G.flash` is the praise channel (PERFECT, DOUBLE KILL), so a weapon arriving there read as another compliment. `G.card` takes an optional `icon` now and draws it above the title; the card does not wait for a tap. `weaponModal` still fires first-sight-only, because that one has rules to teach. **The world board ranks KILLS IN A SINGLE RUN.** Games survived is the same information at a tenth the resolution — most players land between 5 and 20, so a games board is a wall of ties broken by who got there first. Games ride along as the tiebreak and as a column. The prompt to sign it appears on exactly one occasion: a run that beat your own best kill count, on the tap that leaves the death screen. `stats.lastRun()` carries it across `restartRun()` — it lives in the stats module, not on `G`, which has just been restored from `PRISTINE`, and it is consumed on read so it asks once. - **A name belongs to whoever took it first.** The client mints a random `splatterId` once and keeps it; the row remembers it; a submission under an existing name with a different id is a 409. That is the entire security model and it is enough: a stranger cannot overwrite your row. **The id must never be sent to a client** — `public()` strips it, and a leak would hand anyone the name. Clear your browser storage and you lose the name, which is what having no accounts costs. - **One row per name, their best.** The client only posts on a personal best, but its idea of "best" is per-device — the same player on a second phone starts from zero and posts worse runs, so a weaker submission is dropped SERVER-side rather than merely sorted below. - Names come back from the wire and are written by other people. `clean()` whitelists them to `[A-Z0-9 ._-]` and `esc()` escapes them again on the way into `innerHTML`; keep both. - The client's `tidyName()` mirrors `clean()` exactly. When it did not, the server stored `OLIVER`, the client remembered `oliver`, and the board silently failed to highlight the row you just wrote. **Menu art is generated DIRECTLY, not through `_regen.py`.** That pipeline keys magenta and slices figures out of a sheet; these are whole illustrated panels with backgrounds, so they go through `generate_image.py` and a downscale/quantise pass, the same route the court tiles took. Two things learned making them: - **`assets/menu_bg.png` (the splash backdrop) was made by EDITING `court_grass.png`**, not from a text prompt: `generate_image.py "" --input assets/court_grass.png`. Edit mode holds the camera, the palette and the line weight for free, and asking for a portrait aspect in the same prompt worked. Then halve it exactly (`//2`, so the generator's ~4px art-pixel grid stays square — a non-integer divisor shreds this art) and quantise at 128. 768x1376 -> 384x688, 80 KB. - **No python3 on this machine has Pillow.** Run the downscale/quantise step as `~/.local/bin/uv run --with pillow python - <<'PY'`. `generate_image.py` itself needs `requests`, which `/opt/homebrew/bin/python3.11` has. - **Quantise these at 128 colours, not 64.** The panels are big flat fields with a few saturated accents (the ball, fire, blood), which is exactly where a small palette shows — 64 turned the tennis ball beige. - **Find a generated plate by its BRIGHT BORDER, never by the corner colour.** One button plate came back on a near-black margin and the other on a transparency CHECKERBOARD, which differs from itself everywhere and matched the entire canvas. The threshold has to adapt per image too: the checkerboard's own greys are brighter than the darker plate's border. The button plates are blank on purpose — **the lettering stays Silkscreen on top of the art**. Generated type is a lottery and would not match the rest of the HUD. `btn_start` is a BOTTOM band of a taller plate: by there the drips have run out to the ends and the centre is clear enough to print over, and the plate's own bottom border comes with it. **The menu theme starts on the FIRST TAP, never at boot.** A title screen that plays at you before you touch it is rude, and on a phone the autoplay policy would have held it silent until that tap regardless. `loopBed`'s `beds.has` guard makes every later `menuMusic()` a no-op, which is what lets the title screen pick up the track already playing instead of restarting it. **The theme is AAC** (`background-music.m4a`, 768 KB — the WAV was 8.3 MB, and this one downloads on every page load before the player has decided to play). `loopBed` takes the FULL filename now, because the beds are a mix of `.wav` and `.m4a`; `fadeBed`'s key must match it exactly, extension included, or a duck silently no-ops. **A compressed loop needs an explicit `loopEnd`.** AAC pads the final packet, so Chrome decodes this 48.000 s master to a 48.0174 s buffer — 17 ms of silence spliced in at every seam, once a loop, on the title screen. `menuMusic` passes `48`. Re-encode the file and re-measure: `fetch(f).then(r=>r.arrayBuffer()).then(a=>new AudioContext().decodeAudioData(a)).then(b=>b.duration)`. `zombie-mode` got the same treatment: 5.9 MB of 24-bit WAV to 287 KB, `loopEnd` 20.47077 (982597 frames at 48 kHz — an un-round master, and the decoder returns 20.4787, so 7.9 ms of padding is trimmed). Both beds are AAC now; nothing in `assets/audio/` is still a multi-megabyte WAV. **A SPRITE THAT LOOKS "SHRUNK" IS AN ANCHOR BUG, NOT AN ART BUG.** `_fine.py` scales the whole generated sheet so that `--anchor`'s frame is exactly 224 px, and `drawChar` then draws every sprite at `img.height / SPRITE_H`. So if the anchor frame is one where a PROP goes above the head, 224 px has to cover the prop too and every body on that sheet comes out a head short. `opponent_play` was `[o_toss, o_smash, o_swing1, o_swing2]` with `anchor: 0` — anchored on the TOSS, arm and ball stretched overhead — which is why the opponent visibly shrank whenever he served or swung (o_swing1 came out 162 px against a 224 px standing pose). **Every batch must lead with a plain standing figure via `ref`, which `_fine.py` anchors on and `_regen.py` then discards** — the shape the player's own batches already used. Correctly anchored, a toss frame is TALLER than 224 (278 px here) and the body renders at full size. Quick audit: a standing frame should be ~224, a raised-arm frame >224, and nothing should be well under it. **EVERY ROLL DRIFTS THE PALETTE, SO THE PIPELINE CHECKS IT NOW.** A white headband on six frames, pale grey shorts on the toss twice, brick-red shirts and brown shoes on the swing — all of it got past `--tries` because nothing measured colour. `check: "same"`/`"loose"` skips `colour_match` entirely, and `colour_match` is a global mean/sd fit that is far too weak to pull brick-red back to red anyway (tried it; and a nearest-colour palette lock is worse — it drags the duller reds into the racket's browns). So `_regen.py` has `kit()` and `kit_off()`, and a batch opts in with `"palette_ref": "o_idle1"`: - **redness** `r - mean(g,b)` over saturated warm pixels. Brightness alone does NOT work — a brick-red shirt is exactly as bright as a red one, which is why the first check missed it. - **shorts** mean brightness of mid-tone neutrals. Charcoal ~61, the pale-grey drift ~83. - **bright%** share of the body that is near-white — the shoes and socks. Brown shoes drop it to 0.9%, pale shorts push it to 7.9%; correct frames sit at 2.7-2.9%. Frames known to be right cluster at redness 99-110 / shorts 60-63 / bright 2.7-2.9, and every visibly-drifted frame fell outside on at least one axis. Tolerance `(12, 10, 1.5)`. It immediately rejected five bad rolls in a row and then passed a good one. **The `palette_ref` must be a frame the batch is NOT regenerating**, or it validates against itself. **`ref` feeds the frames you are REPLACING back into the reference sheet.** `ref_sheet` is built from `[lead] + sprites`, so regenerating an already-drifted frame shows the generator that drift and it faithfully copies it — the swing failed six straight rolls this way, each one re-inheriting the brick-red shirt from the frame it was about to overwrite. Breaking the loop is one line: copy a canonical frame over the targets first (`cp o_idle1.png o_swing1.png`), so the sheet is colour-correct and the prompt supplies the pose. It passed on the very next roll. **The opponent was running with empty hands.** `o_run1`/`o_run2` had no racket in either fist, and the cause was the batch prompt: `opponent_run` never said the word "racket" and its reference sheet was built from the racket-less frames it was replacing, so the generator had nothing to copy one from. Fixed by making it a `ref_only` batch off `o_idle1` — same man, front-facing, racket unmistakably in his right hand — with the subject spelling out that he still carries it mid-stride. **When a prop goes missing from a sprite, check the batch's `subject` before blaming the roll.** Measuring which audio files actually loaded: `performance.getEntriesByType('resource')` is USELESS here — the ~200 sprite PNGs blow past the 250-entry resource-timing buffer and every later fetch is silently dropped from it. Read the network log instead. That cost a wrong "the bed never loaded". ## Session log: the serve is the gesture (2026-08-01) Oliver's notes on the art pass: the zombies are good, the runner "flickers very hard", and the player has three problems. The serve is now a drawn wind-up driven by the pull. - **The serve is THREE poses, not an animation.** Waiting: the bounce stance. Drawing the slingshot: `p_pull`, the racket dropping behind him. Release: `p_smash`, the drive. A four-stage version keyed to `pullFrac` was built, wired late (frame 1 owning the first half) and played, and Oliver cut it back to this — on a phone, at thumb speed, the middle stages are never read. The intermediate frames are deleted, not commented out. - **The whole player is RIGHT-HANDED now, and that was the real bug.** `p_idle`, `p_bounce` and `p_toss` had been drawn holding the racket in his LEFT hand while `p_smash` and `p_swing` used his right, so any new frame copied whichever reference it was given and the racket changed hands mid-serve. Everything from the idle to the wind-up was regenerated with the hand named explicitly: **drawn from behind, his right hand is the RIGHT-HAND SIDE OF THE IMAGE**. Only the racket HEAD crosses behind his back in the deep frames — the arm never does. - **The bounce pair is one drawing from one sheet**, differing only in the free hand, so the swap cannot flicker; it follows the BALL (`|sin| > 0.5`) rather than its rising phase. He pats the ball with his LEFT hand now, so `BOUNCE_IDLE` moved to the other side and was re-measured off the sprite (`dx` 0.36, `apex` 0.68). The served toss moved to `+0.12` — nearly over his head, because the tossing hand is on the left and the racket meets it on the right, and a big offset either way is wrong for one of those two frames. - **The runner** still carries a hand-mirrored fix from the previous commit (see OPEN) — its frames need regenerating, not mangling. **THE RULE, from Oliver, that this session existed to learn:** *"fixing generated art by mangling with it afterwards is always worse than regenerating. it's just a fact."* Mirroring a frame, shearing a limb, cutting a prop out — a wrong frame is wrong in more ways than the one you spotted (perspective, foreshortening, where the weight sits) and none of those survive a flip. There was an `assets/_derive.py` doing exactly that; it is deleted. Fix the PROMPT and re-run the batch. **THE WAY TO GET A POSE RIGHT IS A PHOTO, NOT A PARAGRAPH.** Describing a serve in prose and re-rolling cost about fifteen generations and never landed — the racket changed hands, the four stages came back near-identical twice, the coil came back as a fold. Oliver's fix: give the generator the real thing. `scratchpad/pose_ref.png` is the recipe — his 10-frame photo sequence cropped to five poses in a row, with two of our own sprites underneath as the character and style anchor, and a prompt saying which strip means what. **One** generation returned the whole serve as our man in our kit, and the frames were sliced straight out of it (anchored on the standing figure it drew, which is what sets the scale). Note it also redraws the reference strip above the result — harmless, crop to the magenta band before slicing. **Two pipeline guards were added to `_regen.py`, each after a bad sheet installed silently:** 1. **The reference's ASPECT is the answer's aspect.** One tall reference figure gave a portrait reference canvas and the generator answered with a 2x2 GRID, which `_fine.py` cannot cut (it segments columns). `ref_sheet` now widens to a landscape strip with one slot per frame asked for. 2. **A non-magenta background is a rejected roll.** "It sometimes ignores the magenta" was written off as harmless because the key uses the corner colour — until it returned a GREEN sheet and keyed away four grey-green racket heads with it, installing four men holding a grip and nothing else. **And `_fine.py` takes `--defringe N` (default 2).** Each de-fringe pass eats a one-pixel line, and a racket frame IS a one-pixel line: two passes were shredding every racket on the sheet. Detailed art wants 1 or 0 — there was no magenta halo left at 0 on the serve sheet, because the palette quantisation snaps to the sheet's own colours anyway. Also worth knowing: a batch of raised-arm poses has no honest scale to anchor on, so frame 0 is always a throwaway STANDING frame (`p_pull0`, deleted after installing). And a photo reference of a real serve is what finally got the poses right — Oliver supplied a 10-frame sequence, and the four stages are its frames 3, 4, 5 and 6, the last being maximum tension before release. ## Session log: the art-resolution pass (2026-07-31) **What changed.** The whole character roster was redrawn at real pixel-art resolution, plus a batch of gameplay fixes. Everything is on `main` and deployed; the start screen shows a BUILD timestamp so a phone can be checked against `git log -1 --format=%cd`. Gameplay this session: the horde machine-gun buffed until a grown wave is survivable (rate scales with hordeGrow, fat pellets, 2 hp each) and `hordeRate` raised to leave a standing crowd behind; the steward ('d') added as an ordinary body between shambler and brute; a pending horde can no longer fire into a boss fight; bodies stopped blinking on the way in (negative-z modulo); the ball machine rolls, dents, and dies as machinery with smoke; the player's death is five drawn frames with the crowd converging and dispersing; wounded bodies walk; side-on zombies mirror when they walk right; the racket zombie's cycle steps off ground covered. **The lesson that cost the most.** The prompt sets the art resolution, and I set it wrong for eleven sheets by asking for "big chunky pixel blocks, flat colour, no shading". See pipeline note 9. **The lesson that cost the second most.** THE REFERENCE SHEET IS WHAT THE GENERATOR COPIES. Building it out of the sprites you are trying to change reproduces exactly what you wanted gone — a front-facing player stayed front-facing and a side-on runner stayed side-on through three retries each. `_regen_batches.json` supports `ref_only` for this: the frames it LOOKS at, separate from the frames it replaces. Draw the back view from the one true back-view frame; draw a front-facing sprint from a front-facing zombie. **Anything that must stay pixel-identical between two frames: do it in code, not in a prompt.** The brute's wound moved between his two feet across three separate regeneration attempts. B frames are mirrors of their A frame now. **Known limitations, in priority order for the next session:** - `z_gwb` (brute, one arm gone, dragging the pole) is a LEG-BAND mirror of `z_gw`, because a whole frame mirror swaps the pole into the other hand. A leg mirror keeps both feet in the same two positions, so it reads as a leg swap rather than a stride. The fix that worked for the shamblers: generate the second frame as a PASSING pose (feet together under the body, one knee lifted) with the prop explicitly in the same hand — a walk needs the foot GAP to open and close, which is the measurement to check (`z_aw` 75px -> `z_awb` 65px is a real stride; equal gaps are not). - Brute damage stage 3 was drawn slimmer than the others once; watch that the four stages keep one body size (they are drawn from `z_g1` in two sheets now). - "Player looks transparent while running" was reported and then reported gone. Never reproduced: the run frames have zero semi-transparent pixels and every globalAlpha in render.js is save/restore'd. If it returns, establish first whether the court shows THROUGH him (sprite) or he flickers (renderer). - The chapter bosses, the judge, the ball machine and the weapon icons were deliberately NOT redrawn — Oliver named them as already right. **A sprite's drawn size comes from its TOTAL height**, so anything held above the head eats into the body. The serve frames were 29% racket by height and the man came out a fifth too small; they are scaled so the BODY matches the standing frame (98% of sprite height) and the racket overhangs. Check this on any new pose with a raised prop. ## Gotchas learned the hard way - **Nothing outside the four slot accessors may know how many slots there are.** `endBurst` had `[0, 1, 2, 3, 4].find(i => slotId(i) === id)`, which was correct for exactly as long as the rack held five. `RACK_MAX` 4 → 9 and a burst weapon in slot 5..9 was suddenly unfindable, so it was never spent: beacon, flamer, rocket and gun sat at their pickup count forever. Use `slotOf(id)`. The serve weapons were untouched because they all spend through `spendSlot(G.weapon)` — the LIVE index, never a search — which is why the laser looked fine while the other four did not. Note also that spending re-indexes the rack (`spendSlot` splices), so a slot number goes stale the moment any tin empties. Look weapons up by ID, not by a slot you cached a frame ago. - **A modal freezes the WORLD, and `G.flash` is part of the world.** Its tick used to sit above the `if (G.modal) return` in `update()`, so any flash raised in the same beat as a popup spent its whole 1.1 s counting down invisibly behind the dim. Harmless while every tin opened a modal of its own; the moment `weaponModal` became first-sight-only, the label flash WAS the announcement and the trainer's popup ate it on every wounded duel win — *"later in the game a won duel does not give you weapons anymore"*. Anything transient and drawn belongs below that return, not above it. - **`deliverTin` walks the ladder, it does not cycle it.** `TINS[G.deliveries++ % 4]` handed you whatever rung came next even when that type was already at `TUNE.wCap`, where `grantWeapon`'s `Math.min` clamp made the whole delivery a no-op. Past roughly the thirteenth duel three rungs of four are full, so most late duels paid out in nothing at all. It now advances to the first rung with room, and only says `RACK FULL` when every one of them genuinely is. - **Every sound in audio.js swallows its own errors** (`try { ... } catch (e) {}`), so a clean console proves NOTHING about whether one played. To verify a sound really built its graph, count nodes — patch `AudioContext.prototype.createBufferSource` / `createOscillator` / `createBiquadFilter` and diff around the event. To verify a PARAMETER reached the synthesis, patch `AudioParam.prototype.setValueAtTime` and look for the frequencies you expect (that is how the size scaling on `thud`/`splat` was checked: 150 Hz body thump on a shambler, 100 on a brute, 59 on a chapter-4 boss). - **A test that walks the game forward will quietly kill the player.** Three separate diagnostic runs above returned nonsense (`stillCarried: true`, no laser fired at all) purely because `G.mode` had become `'eaten'` several steps earlier and every gameplay entry point bails on that. When a probe says a feature is broken, check `T.G.mode` before believing it. `setInterval(() => T.zombies.length = 0, 30)` for the duration of the test is the cheap fix. - **Stale ES modules are the #1 time sink.** python http.server sends no cache headers, so an edited module often loads STALE. `preview_stop`/`preview_start` only helps if the port actually changes, and holding 8641 with a throwaway socket **did not work reliably**. What DOES work: ```js await Promise.all(['main.js','state.js','gameplay.js','render.js','config.js','zombies.js', 'input.js','audio.js','panel.js','dom.js'].map(f => fetch(f, {cache:'reload'}))); ``` then reload the page. **Always sanity-check a just-added field before trusting any result** — and note the trap: if `state.js` is stale but `gameplay.js` is fresh, a new `G` field materialises as **NaN** (undefined + dt), which reads exactly like a logic bug. - `drawChar` mirrors sprites by `ch.facing` — any stance that must not mirror needs facing reset. - Synthetic touch tests: input.js only sets `stick.moved` in touchmove, so touchstart→touchend reads as a TAP. Always dispatch a touchmove between them. - Contact/kill tests must step 1/60 — pellets at 26 m/s tunnel past 0.55 m radii at coarser steps and kills silently don't happen, which looks exactly like a bug. - **When testing fire or any serve, fire a real ARC.** A flat ball from z=16 hits the net (netH 0.914) and bounces back at you; a "the weapon is broken" reading was my test hitting the tape. - The scratchpad venv gets wiped between sessions — recreate it with `requests` + `pillow`. - Commit style: imperative summary + `Co-Authored-By: Claude Opus 5 `. Push = deploy. --- ## How to work on this — the meta-lesson from four playtest rounds **Every single "this feels odd" note Oliver gave this session turned out to be a real defect, not taste.** Not one was a tuning preference. The score so far: | what he said | what it actually was | |---|---| | "the racket zombie doesn't seem harmable, but that could just be a feeling" | 3 of 5 weapons silently no-oped on it | | "I could not return a single ball" | the parry was unreachable — the tap only became a swing in `rally` | | "the first boss is way too easy" | a flame ball re-wounded it every frame; one killed a 16 hp boss | | "the ball is not yet at the player but he is already hit" | contact fired 0.68 m early (weighted depth) | | "one of the endboss sprites is used for a normal zombie" | a case-insensitive filename collision | | "everything is a tad too small" | near sprites were being upscaled 1.5x from a 112px source | | "I die all the time at the game-20 horde" | the horde scales, the panic button was a flat 1 s | So: **when he reports a feel problem, go measure it before touching a tuning value.** The measurement is usually cheap (project both objects to screen space at the moment of the event; count device pixels against source pixels; print the schedule for 45 games) and it has found a bug every time. **Verify with instrumentation, not with screenshots.** Small pixel art at phone scale is genuinely unreadable in a screenshot — during the boss-design work I twice concluded from screenshots that the sprites were fine when they were all silently falling back to design 1. What settled it in one call: ```js const o = CanvasRenderingContext2D.prototype.drawImage; CanvasRenderingContext2D.prototype.drawImage = function (img, ...a) { log(img.src); return o.call(this, img, ...a); }; ``` Same idea elsewhere: project to screen coords and compare numbers, don't eyeball. A silent fallback (`SPR[wanted] ? wanted : `) is convenient in production and *poisonous* while debugging, because a wiring bug renders as perfectly working code. **Assert your scripted edits matched.** A `str.replace` that finds nothing is a silent no-op. One of those left `drawZombie` on hardcoded sprite names while everything else moved to the `BOSS_ART` table, and the fallback above hid it completely. Every scripted edit in this repo should `assert a in s`. --- # OPEN — leftovers, in rough priority order Nothing here is broken; these are known gaps and unverified risks. 1. **`RES` 4 is unverified on a real phone under load.** It nearly doubled the fill rate (810 -> 1080 device px across). Oliver has not reported stutter, but he also has not played a late horde since. **If the game ever stutters, `RES` back to 3 is the first move** — not reducing zombie counts. 2. **The game-20 horde fix is unplayed.** The crowd-scaled swish (1.00 s at one body -> 0.22 s at fourteen) is measured but not yet felt. If it now trivialises hordes, `meleeCoolMin` is the knob. 3. **`p_idle1`/`p_idle2` and most `o_*` frames are 2x-upscaled, not re-sliced.** They are on the right scale but gained no detail in the remaster, and they are the two most-seen poses in the game. Fix by hand-slicing `_sheet_player2/3` and `_sheet_opp2/3` at `IDLE_TARGET_H` 224 rather than going through the signature matcher, which assigned those frames elsewhere. 4. **Boss designs are asymmetric — still, for two of the six.** Designs 1 (GROUNDSKEEPER), 4 (HEAD COACH), 5 (PHYSIO) and 6 (SPONSOR MASCOT) have 3 wound stages and 4 throw frames each; designs 2 (LINE JUDGE) and 3 (CHAIR UMPIRE) still have 1 wound skin and 2 throw frames. Legal — the `BOSS_ART` indices are clamped — but a chapter-2 boss degrades in one visible step instead of three. Fixing them is now just two batches in the shape of `boss_coach_wounds` / `boss_coach_throw`. 5. ~~**Only 3 boss designs, cycling.**~~ CLOSED 2026-08-03 — six now, so a chapter does not repeat a design until chapter 7. A seventh is still a sheet plus one row in `BOSS_ART`. 6. **Landscape is under-maintained.** The readability pass (camZ/camY/camF/horizon), the adaptive height and the safe-area work are all portrait-only; landscape still runs the old camera and a fixed 480x270. Portrait is the stated primary target, but landscape has drifted and is untested. 7. **`drawCard` (the chapter/set cards) still cuts in** with no entrance, while modals swoosh (`TUNE.modalIn`). Offered and not yet requested. 8. **A chapter can be skipped.** `G.bossDue` takes the max, so going a long stretch without ever clearing the court means you face the current chapter, not a backlog. Deliberate; noted in case it ever reads as a bug. 9. **The runner's four frames were fixed BY HAND and must be regenerated.** `z_f1..4` are currently hip-registered (padded to one width with the hip on the centre line) and frames 3 and 4 are mirrored copies, because all four were drawn leaning the same way and lifting the same leg. That is exactly the post-processing Oliver ruled out — it shipped before he stated the rule. The batch to write asks for a four-frame front-on sprint whose stride and lean ALTERNATE, and the numbers to check afterwards are the per-frame hip centroid (it was 13 px of lateral jump at 12 frames a second) and which leg leads. 10. **Step 6 stays PARKED** by Oliver's decision: the crowd turning into zombies (spawning from the stands — attacks from behind, high spectacle, `x_crowd` sheets already exist) and the ball-boy zombie that catches your shots. Recorded so the ideas are not lost, not as a to-do. 11. **`ump_no_deal` does not exist.** Oliver asked for its removal; `ump_no_appeal` was removed as the closest match. Worth one confirmation that no other line is still unwanted. 12. **Four sounds are missing and need RESEARCH first** (find/choose the source before implementing — every existing sfx is either synthesised in `audio.js` or a wav in `assets/audio/`): - the **ball machine** while it moves — a squeaking wheel/castor, so the furniture announces itself. It already telegraphs its shot (`machWind` 0.9 s); this is the *approach* cue. - **burning zombies** — fire is an epidemic now (`burnSpread`, `burnTime` 3 s), so this is a looping crackle plus something the body does, and it has to layer without turning a lit-up wave into noise. - the **melee gun** — i.e. the machine-gun burst (`mgRate` 12/s during a horde, `mgPickupLen` 8 s from a tin). At 12 rounds/s a per-pellet sample will machine-gun the mixer, not just the wave. - **boss sounds** — footfall, wind-up, throw, wound, death. Three designs in `BOSS_ART`; decide whether they share one set or get one each before recording anything. --- # LATER — shipping it: demo, stores, campaign Decided in discussion with Oliver, 2026-07-31 and extended 2026-08-06. **None of this is built** — the one thing that exists is the domain (item 5). Recorded here so the decisions survive; the ordering below is roughly the order to do it in. **The model: paid, $2.99, no ads.** Both stores. No ad SDK, no consent/CMP flow, no ATT prompt, no tracking — which keeps the store card at "Data Not Collected", keeps review trivial, and keeps a zero-dependency project zero-dependency. *Maybe* later: a free build capped at chapter 1 with an IAP to unlock all chapters. That is a fallback, not the plan — paid→free is a normal move, free→paid is not, so launch paid. **Ads are rejected**, with one exception held in reserve: rewarded video on the CHALLENGE (below), never an interstitial. An interstitial after death would land exactly in the "one more run" gap that `EATEN_RESTART` exists to protect, and would end sessions rather than annoy. 1. **The capped web demo.** The public web build stops at the **end of chapter 1** — after the first chapter boss is beaten (`G.runGames >= 10`, `bossEvery`), on the chapter-2 card, ending on a WIN, not a wall: "CHAPTER 2 — continue on the App Store." The cut must sit *after* everything a promo clip promises (transition, first horde, a duel, weapons, the boss), because the web build's whole job is converting a 15-second clip into "I played it". Implementation is ~10 lines: a check on the chapter-2 card plus a build flag the store build sets to skip it. **Build no protection around it** — it is client-side JS on a static host, trivially bypassable, and nobody who would pay $2.99 will open devtools. DRM here is pure cost. Consider leaving **desktop uncapped** (`matchMedia('(pointer: coarse)')`, already used for `autoAssist`): nothing to cannibalise there, and an uncapped desktop build is what gets linked. 2. **Store readiness.** Wrap the PWA (Capacitor/PWABuilder). Unknowns to check *before* committing: audio behaviour in WKWebView, safe-area (already solved for the browser, may differ in the shell), and 60 fps canvas under WKWebView at `RES` 4 — see OPEN 1, this is the second place that risk lands. Costs: a build step in a deliberately build-step-free project, Apple $99/yr, Google $25 once. Landscape (OPEN 6) probably has to stop being under-maintained before a store review. 3. **The CHALLENGE — ship it FREE.** Tennis already owns the paid-continue mechanic: Hawk-Eye. On death (`G.mode='eaten'`), the umpire announces a challenge, the replay shows the shambler's foot over the line, and the call is **overturned** — you resume. The sport supplies the cap for free (challenges per set), so the limit is diegetic rather than a paywall excuse. Build it as an **earned** mechanic, not a purchase. It is good design on its own, and it is the only slot in the game that could ever carry a rewarded video if the model changes. Note it does change what a run *means* — right now the run summary is the whole game — so it needs a playtest on feel before it is assumed good. 4. **Short-form video campaign.** The only free channel that reaches people who have never heard of the game; the algorithm serves cold, so every post is a free lottery ticket. Format: vertical (portrait is already primary — no letterboxing, unlike most game footage), open mid-rally on what looks like an ordinary SNES tennis game, reveal the transition at ~3-5 s. Sound matters: the recorded umpire over splatter is what earns an unmute. Volume over polish — 20-30 clips, one export cross-posted to TikTok/Reels/Shorts. **No devlog content** (it reaches developers, who do not buy games). Clips the game already contains: the transition (the flagship), a boss walking on, the ball boy calmly mopping blood, a flame ball starting an epidemic across a lined-up wave, parrying a boss's volley back into him, a horde, the death + run summary. A capture helper on `window.T` to jump straight into a given beat (the transition timeline is on `G.transT`) would make clean takes cheap — not yet built. 5. **How a viewer actually reaches the game — the name does the work, not the link.** TikTok and Instagram have no clickable caption links, so the bio link costs two taps (profile, then link) and converts in the single-digit percents. YouTube Shorts descriptions and pinned comments DO click, which makes Shorts the best converter of the same upload. But **most people who liked the clip click nothing and search later** — so the whole funnel rests on whether the name is memorable and resolves. Three consequences, all decisions rather than work: - **the name goes on screen in every clip** — not a title card at the start (scrolled past), a small persistent corner mark or an end frame after the payoff; - **the account handle IS the game name** (`@splattertennis`), which bakes the name into the platform's own UI on every video, permanently, for free — this does more than the bio link; - **pin a comment with the name** on every video; "what's this called?" is how interest shows up. The profile page is the real landing page: it should read as a game's page, not a personal one. **The domain is registered: `splattertennis.com`** (Oliver, 2026-08-06). It matches the searched name, which `splatter-tennis.wuwi.one` never could — nobody types that from memory. Point it at the demo build and use it as the single link everywhere. The deploy target itself is unchanged (see the top of this file); this is DNS in front of it, plus whatever store links live on the page. 6. **Test the game inside the in-app browsers BEFORE the campaign runs.** A bio-link tap does not open Safari or Chrome — it opens TikTok's or Instagram's embedded webview, so **that is the first-run environment for most players the campaign delivers**, and it is the least-tested one this project has. Specifically unknown there: audio autoplay policy (the umpire is the hook — if he is silent on first load the clip's promise breaks), safe-area insets, whether PWA install is offered at all (it is not), and canvas perf at `RES` 4 — the third place that risk lands, after OPEN 1 and item 2 above. `matchMedia('(pointer: coarse)')` does still work there, so the mobile branches are fine. **Expectations, so nobody is surprised:** with no marketing, 20-100 units in year one is the honest central estimate (~$40-210 net, i.e. under Apple's fee at the low end). The monetization model is worth maybe $200 either way; the video channel is worth ~100x that. Ship paid because it is cheap and protects the design, not because it will earn. **Explicitly NOT to be built** (these fight settled decisions, see the invariants above): - **selling weapons or tins** — weapons are *only ever won*, in the duel; a shop deletes the duel's reason to exist and `healPerDuel` with it. - **a meta-progression currency** — permadeath and the run summary *are* the meta layer. --- # CLOSED — Oliver's fourth playtest (2026-07-30) ### The game did not use the full height of his iPhone Fixed by making the portrait logical height adaptive and letting `horizon` ride with it — see the invariant above. On a 375x812 pane: logical 270x585 (was 270x480), canvas CSS 375x812, **100% of both axes**. `RES` 3 -> 4 for the "extra pixels" he asked about. ### Dying on every game-20 horde The swish cooldown now scales with crowding — see the invariant. Note the ordering trap recorded there; the first implementation measured the crowd after the swipe had already killed it, so the cooldown only moved once ~14 bodies were present. ### The weapon message was lost when the heal popup fired `showModal` deliberately never clobbers an open modal, so winning a duel while wounded opened the heal modal and the weapon modal was silently dropped. `deliverTin(healLine)` now folds the trainer into the WEAPON popup as an extra line, so there is one popup carrying both. An unwounded duel win still shows the plain weapon popup. ### The mop boy walked out with nothing to mop `sendBoy` now returns early when `mop` is set and no stain passes the same test `mopRound` uses to pick a target (`a >= 0.15 && z >= COURT.netZ - 2`) — he was walking on, finding nothing reachable, and walking straight off. This needs `state.js` to read `gore`, which adds a **third runtime-only import cycle** (state <-> zombies) alongside input<->gameplay and zombies<->gameplay; it is safe for the same reason, `gore` is only touched inside `sendBoy` at runtime, never during module evaluation. ### Regression net 30 tapped tennis points = 50 hits / 0 whiffs. 45-game soak: no exceptions, all four chapters, `mgT` drains, gore 40 / piles 30 / bodies capped at 34. --- # CLOSED — Oliver's third playtest: audio, hit timing, readability (2026-07-30) ### The umpire talked too much and at random Rebuilt as situational triggers behind one shared cooldown — see the invariant above. `asideEvery` 26 s -> 55 s. Removed `ump_out_of_order`, `ump_let`, and `ump_no_appeal` (he asked for "ump_no_deal"; that line does not exist and `ump_no_appeal` is the one he meant — confirm if a different line is still unwanted). 11 `ump_*` lines remain. ### "Visually the ball is not yet at the player but somehow the player already is hit" Real, and measurable. The hostile-ball hit test reused the PARRY's `hypot(dx, dz * 0.8)` — weighting depth by 0.8 SHRINKS it, so contact fired while the ball was still most of a metre up-court. Measured at the moment of the hit: `dz = -0.68 m`, and the ball drawn at screen y 314 against a head at 305 — up around his head, not on his body. Fixed with a separate unweighted test (`TUNE.shotHitR` 0.42, `shotHitY` 1.75). Re-measured: contact at `dz ≈ -0.25 m`, ball at screen y 325 against head 305 / feet 362 — squarely on the chest. **The parry keeps its 0.8 weighting on purpose**: a racket sweep really is wider than it is deep. ### "Everything is a tad too small" (his wife, and the best note of the project) Measured before changing anything, because "regenerate the sprites at higher resolution" is only half right. With `RES` 3, drawn DEVICE pixels vs the 112px source were: ``` player at baseline 170 px UPSCALED 1.52x <- higher-res art helps shambler your half 120 px upscaled 1.08x <- helps shambler mid-court 98 px downscaled <- cannot help; needs to be BIGGER shambler far 78 px downscaled 0.70x <- cannot help ``` So near bodies needed resolution and far bodies needed the camera. Both were done. **Camera** (portrait only; landscape untouched): `camZ` 42->48, `camF` 580->760, `camY` 9->11, `horizon` 90->45. Pushing the camera back while lengthening the lens FLATTENS the perspective, so distant bodies grow relative to near ones instead of everything merely zooming; `camY` rises with it for a steeper look-down so bodies overlap less at depth, and `horizon` re-centres the court after both. Result: far shambler 26 -> 30 logical px, mid 33 -> 36, chapter-1 boss 39 -> 45, **player unchanged at 57**, court band 200..390 (was 199..380). If you retune these four, check all four outputs: far size, near size, court top, court bottom. **Sprite remaster, at zero generation cost.** The original `assets/_sheet_*.png` files are retained at full generated resolution (~1400px wide), so nothing needed regenerating — `IDLE_TARGET_H` went to 224 and every sheet was RE-SLICED. The sheet-to-output mapping was never written down, so it was recovered automatically: slice each sheet to a temp dir, then greedily assign frames to existing assets 1:1 by LANCZOS-normalised RGBA signature distance. 58 sprites came back from sheets; the 29 with no confident sheet match were 2x-upscaled instead, which adds no detail but keeps them on the shared scale (see the `SPRITE_H` invariant — a mismatched sprite renders at half height). `x_crowd`/`x_crowd2` were reverted to originals: they never touch `SPRITE_H` and doubling them is pure memory. **Known gap:** `p_idle1`/`p_idle2` and most `o_*` frames were among the 2x-upscaled set, so the two most-seen poses gained scale but not detail. Re-slicing `_sheet_player2/3` and `_sheet_opp2/3` by hand (rather than via the matcher) would fix that. ### Regression net 30 tapped tennis points = 50 hits / 0 whiffs. 45-game soak: no exceptions, all four chapters fired, `mgT` drains, gore 40 / piles 30 / bodies capped at 34. --- # CLOSED — Oliver's second boss/ranged playtest (2026-07-30) ### The parry was too generous *"I can tap pretty early and it returns the ball."* At `parryArm` 6.0 / window 0.34 / reach 2.4 the shot flew INTO reach during the window, so any tap once it was vaguely close worked. Now 3.4 / 0.2 / 1.7 — verified: a tap at 6.0 m or 4.5 m is rejected and costs blood, 3.2 m and closer returns. ### The boss's three balls were decoration *"he throws 3 of them from which only ever one can reach me. that does not make sense."* Correct — they were fanned +-1.9 m across space, so only the middle one was ever aimed at the player. They are a **timed salvo** now: three shots `TUNE.bossSalvoGap` apart, each aimed at where you are AT THAT MOMENT with only a hand's width of scatter. Verified firing at 0.02 / 0.45 / 0.88 s, all within 0.3 m of the player. Note `hurtGrace` (1.1 s) still caps a whole volley at one point of damage by design. Reaction time also tightened: bosses get `bossWind` 0.5 s of telegraph against the machine's 0.9, and throw at `bossShotSpeed` 15.5 m/s against its 13. ### Every chapter was the same boss Right — chapters only differed in hp and scale. There are three designs now, cycling, each with its own sheet, a 2-frame stride, a throw pose, a wrecked skin and a wrecked-throw skin: **THE GROUNDSKEEPER** (ch 1/4/7…, blazer + ball hopper, and the only one with three wound stages), **THE LINE JUDGE** (ch 2/5/…, fat, striped officials' polo, cap, ball apron), **THE CHAIR UMPIRE** (ch 3/6/…, tall and gaunt, panama hat, the umpire's chair strapped to his back). The chapter card names the design. Chapter 4 repeats design 1 but at 43 hp and 3.67 m. **The chair-umpire sheet came back on a WHITE background**, and it wears a cream blazer and a white hat — a colour key would have eaten both. Fixed by flood-filling the background inward from the image BORDER instead of keying by colour; that script is in this session's git history and is the right tool whenever a generated sheet's background colour also appears on the character. ### Healing needed to announce itself Winning a duel while wounded now opens a real modal — `x_cross` first-aid tin, "WOUNDS TREATED / THE TRAINER PATCHED YOU UP / N OF M BACK" — plus an umpire line. It was a 1.4 s flash before. ### The umpire needed to comment on the new reality 14 new recorded lines (`say -v Daniel -r 168` -> `afconvert`), all `ump_*`. Ten are a rotating pool of sarcastic asides fired on a jittered `TUNE.asideEvery` timer whenever nothing else owns the audio (`umpireAside` in gameplay.js); four are hooked to specific beats — a machine arriving, a boss arriving, the trainer, and taking a hit. The joke is that he has still never acknowledged that the match became a massacre, so he keeps officiating it: *"Code violation. Unsportsmanlike conduct. Eating."* ### Regression net 30 tapped tennis points = 50 hits / 0 whiffs. 45-game soak: no exceptions, `mgT` drains, gore 40 / piles 30 / bodies capped at 34. --- # CLOSED — Oliver's first boss/ranged playtest (2026-07-30) Six reports, all fixed. Two were serious. ### A boss sprite appeared in the shambler crowd — CASE COLLISION `z_B1.png` / `z_B2.png` overwrote the 'b' shambler's `z_b1.png` / `z_b2.png` on a case-insensitive filesystem. Restored from `HEAD~1`; boss frames renamed `z_boss1`, `z_boss2`, `z_bossw1..3`. The rule is now in the asset pipeline section — this is a trap that leaves no obvious trace. ### The parry was unreachable — the whole mechanic was dead *"I could not return a single ball of the ballmachine.. I tried it with taps near the hit - it never worked"*, and the same for the boss's volleys. Cause: `P.swingT` is only set in `keyPressed`'s `phase === 'rally'` branch, and splatter is in `pretoss` almost all of the time, so the tap tossed the serve ball instead of swinging. An incoming shot now owns the tap in every phase — see the invariant above. Verified returning from `pretoss`, `rally` and `over`, at 0.9 m through 5.4 m, with the three fail cases (too early / too late / no tap) intact. ### One flame ball killed a 16 hp boss Not tuning — a real bug. The flame ball is not consumed on contact (full pierce) and a boss is not removed when hit, so it re-wounded every frame. `zb.hitT` + spending the ball on a boss. Chapter 1 now costs 4 flame balls or 16 clean smashes, and `bossHp` went 10 -> 16 (`bossHpPerChapter` 8 -> 9), so chapter 4 is 43 hp. ### The boss had no wounded state while throwing Correct — the wound skins were standing poses only. New sheet `_sheet_bossthrow.png` gives all four damage states their own overarm pitch (`z_bosst0..3`), used whenever `windT > 0`. ### The ball machine was "naively painted" It was — four rects. Replaced with a generated sheet (`z_mach1` idle, `z_mach2` firing with a muzzle flash): a dented hopper cart on wheels with a barrel aimed down-court. The muzzle GLOW stays procedural on top, because the telegraph has to read at 20 px tall. ### The player's blood read as a painted layer Removed entirely. See the invariant above. # CLOSED — chapter bosses + ranged attacks, steps 4-5 (2026-07-30) ## Step 4 — the ranged channel **The ball machine (`'m'`)**, Oliver's own idea and the most on-brand thing on the list, because a ball machine is real tennis furniture. It never walks, so it never crosses your baseline and never scores: it is a standing threat you choose when to deal with, which is the target-priority decision the game was missing. Measured: 3.4 s cycle, **0.9 s telegraph** (`TUNE.machWind`, muzzle glow + a mechanical click — the same fairness contract the runner's `shriek()` is), 3 hp, exempt from the clear gate. **The projectile is a real ARC**, solved like a serve rather than fired flat, so it reads coming in and so the swing window has something to meet. `fireAtPlayer` aims where you *stand*, which means moving is a partial answer during a rally — and is exactly why the swish path exists for `pretoss`, where you cannot move or swing. **Player damage.** 4 hp, `hurtGrace` 1.1 s, +2 on a won duel (the only healing in the game, and now THE reason to want a duel rather than tolerate it). Verified: 4 hits kill you and land in the same `eaten` path as being caught, with `eatenAt`, the death animation and the best-run write all intact; three hits in one frame cost only one point. ## Step 5 — chapter bosses, and the racket zombie demoted **Demoted, as Oliver asked.** The racket zombie used to respawn on *every* game you won, which is precisely why he read as "the boss" and why a real boss could not have stood out next to him. He is an occasional opponent now (`TUNE.racketEvery` = 3 games). **The chapter boss (`'B'`)** arrives every `TUNE.bossEvery` = 10 games, fired from `proceedAfterClear` — the one beat where the court is guaranteed empty — and it OUTRANKS the set/horde and duel beats there, because a boss walking on into a standing wave would be unreadable. It clears the stage, blocks all other spawns while it lives, and threatens both channels: volleys of `bossVolley` = 3 chip your hp, and letting it reach you is the ordinary instant death. Chapter 1 is 10 hp and 2.7 m; chapter 4 is 34 hp and 3.67 m. **The economy fix this was really for:** tins arrive every 3 games forever, so nothing was ever worth banking. Measured cost of a chapter-4 boss — 12 bombs, or 100 machine-gun rounds, or 34 side balls. `TUNE.wCap` is 9 per type, so **no single weapon can kill a late boss** and a mixed, banked arsenal is mandatory. That is the strategic pressure Oliver was asking for. `G.bossDue` never moves backwards (`Math.max`): going a long stretch without ever clearing the court skips a chapter rather than queueing a backlog. Deliberate — you face the boss the run has earned. ## Bugs found and fixed while building this (all were silent) - A blast **deleted** a 34 hp chapter boss outright: `explode` only wounded `'r'`. This is what `isBoss()` now exists to prevent, and it is the second time the same class of bug has shipped. - The machine's `machHp` did nothing — `'m'` fell through every variant branch to the generic one-shot fallback. It soaks like the brute now (3 balls, verified). - The boss both scored a kill and fired its chapter card, so two code paths set "what happens next" in the same frame. - The machine gated the hand-clear, turning a priority decision into a chore. - A boss death produced a shambler-sized gore burst (`GORE_MUL` had no `'B'`; now 6 → ~305 particles). ## Regression net after steps 4-5 30 tapped tennis points = **50 hits / 0 whiffs** (unchanged — tennis is untouched by any of this). 45-game splatter soak: no exceptions, **all four chapters fired [1,2,3,4]**, machines appeared, ranged hits landed, `mgT` drains, gore 40 / piles 30 / bodies capped at 34. --- # CLOSED — the 50-game plateau, steps 1-3 (2026-07-30) ### Racket zombie's HP bar removed See invariants. Replaced by a one-per-run "IT HAS A RACKET / IT RETURNS YOUR WEAK SHOTS / ONLY A FULL PULL WOUNDS IT" modal from `spawnRacketZombie`, gated on `G.seen.racket`. ### "Sometimes the racket zombie doesn't seem harmable" — it was real, not a feeling Three of the five rack slots did **nothing at all** to him, silently: `TRIPLE` side balls skipped him (`variant === 'r'` → continue), `GUN`/horde pellets skipped him, and fire spread could not ignite him. Meanwhile `BOMB` deleted him outright ignoring hp. All fixed via the new `TUNE.dmg*` table and `wound()`. Verified numerically: 9 pellets kill a 3 hp boss, one side ball takes 1 hp and is consumed, a weak side ball does nothing (correct — the `zSplatQ` gate), a bomb takes 3 off, and he now catches fire from a burning neighbour and cooks to death in 3.6 s at `bossBurnMul` 0.4. - **`hitR(zb)` gives him a 0.7 m contact radius** (shambler 0.55, brute 0.75) — part of "unharmable" was simply missing him. - **Melee is still skipped, and that is correct, not an oversight.** The player is bounded at `P_BOUNDS.zMin` = 13.1 and the boss holds `z <= COURT.svcFar` = 5.5, so they are always on opposite sides of the net and `meleeRange` 1.7 can never span it. Don't "fix" an unreachable path. - **A sub-threshold ball still passes through him on purpose** so `racketAI` can rally it back. That rally IS the feedback that the pull wasn't hard enough, so there is deliberately no clank there. ### The director un-capped Old vs new, verified in-browser (gap s / body cap / speed m/s / advanced share / boss hp): ``` gap cap speed nasty bossHp g 1 2.59/2.78 3/4 0.79/1.75 0.03/— 3/3 g10 1.15/1.00 12/13 1.61/1.75 0.30/0.27 4/3 g12 1.03/1.00 13/14 1.79/1.75 0.36/0.42 4/3 g20 0.71/1.00 21/14 2.52/1.75 0.60/0.42 5/3 g30 0.52/1.00 24/14 2.73/1.75 0.75/0.42 6/3 g50 0.35/1.00 24/14 2.73/1.75 0.75/0.42 9/3 ``` - The spawn gap is **hyperbolic** now (`zSpawnEvery / (1 + games * zSpawnAccel)`) so it keeps tightening instead of hitting a floor; `zSpawnMin` is only a safety stop. - **Known, deliberate early-game change:** shamble speed used to be TIME-based (`splatT / 70`) and maxed out after 105 seconds of splatter — i.e. during game 2 — so it was never a progression lever and every early shambler already walked at top speed. It is games-based now, which makes games 1-10 genuinely gentler than before. Oliver has not playtested that yet; if the on-ramp feels flabby the knob is `zSpeedPerGame`. - Hordes scale with sets banked (`hordeGrow`) — length AND spawn rate. **`TUNE.zHordeCap` = 34 is required**: a horde deliberately ignores the director cap, and with growth a late wave asks for ~120 spawns where the old fixed 12 s x 2.5/s could only ask for 30. A 41-body court showed up in the soak before the cap went in. ### Regression net after all of it 30 tapped tennis points = 50 hits / 0 whiffs (unchanged). 40-game splatter soak: no exceptions, `mgT` drains to 0, gore 40 / piles 30 / bodies capped at exactly 34. --- # CLOSED — Oliver's 35-game retest (2026-07-27), all fixed 2026-07-30 Kept because each one is a trap worth not re-digging. Every fix was verified in the browser via `window.T`; the numbers quoted are from those runs. ### 1 + 6. Player frozen (and stuck mid-animation) during the between-game pauses — was HIGH **Two fixes: the first was wrong.** The movement block — pose update included — lived inside `if (G.phase === 'rally')`, so every 'over' beat and every `pretoss` left the player standing while zombies walked at him. The `pretoss` case is issue 6: `endHorde` calls `setupPoint()`. The first attempt widened the block to all of splatter except `'toss'`. Oliver vetoed it on sight: *"the mechanic where I suddenly can move freely around feels broken.. I basically can never walk around freely - also I could walk off the screen"*. He is right — free movement for five seconds between points, in a game where you otherwise only move while a ball is live, reads as a bug. It is reverted, and the rule is now in Settled feel decisions above. What actually ships, three separate pieces: - **The dead wait too.** `held = !!G.changeover` in `updateZombies` stops every zombie's `z` advance, its x steering, and the eat check for the breather's duration. They still bleed, still burn, still animate — they just stand there, because the umpire called time and it is still a tennis match. This is Oliver's own second option and the characterful one (the boys mop, everyone waits). - **The stuck pose.** `expirePose` only ever expired `swing`/`serve`; `'run'` was the pose nothing reset, and `P.runT` stops advancing outside the movement block, so a beat that began mid-stride welded him to one run frame. An `else if (P.pose === 'run')` on the movement block fixes it for every phase at once. - **The on-screen clamp** (see Settled feel decisions) — `xVis` already existed but was applied only in auto-fire modes, so ordinary play could walk clean off the edge. Now it always applies. **The path where this actually bit** — worth knowing, because a clear court makes it look impossible: a changeover normally starts only after `G.clearing` has emptied the court, so nothing is walking. But `resolveInterlude('O')` spawns **five fresh zombies** ("the dead lose their patience") and *then* hands back to `startChangeover` via `G.afterDuel`. Lose a duel scheduled on a changeover beat and you get five shamblers plus five seconds with no agency. Verified fixed: they advance 0.000 m across the breather and resume 0.75 m/s the instant "Time." is called. If the 5 s still drags on a phone, `TUNE.changeoverT` is the knob — it is now a safe, watchable beat rather than a dangerous one, so shortening it is purely a pacing call. ### 2. The mop boy walks on during the 1:1 duel — was LOW `sendBoy` now returns early on `G.interlude`, and `startDuel` sets `G.boy = null`. Note the ordering that makes this safe: `resolveInterlude('P')` clears `G.interlude` **before** calling `deliverTin()`, so the boy still comes out with the tin. ### 3. Triple serve: the two wing balls lob off at the wrong height — was MEDIUM The diagnosis in the old text was right. `g: ball.g` is now copied onto each clone (both push sites: `hitServe`'s triple fan and `dragServe`'s re-fire clone) and `zombies.js` integrates `e.vy -= (e.g || TUNE.gravity) * dt`. Verified at full pull: clones carry g = 21.16 (2.3 × the 9.2 base) instead of 9.2, and centre and wing apex heights now match exactly (2.90 m both). ### 4. The umpire calls "Time" on pauses that aren't changeovers — was LOW `G.changeover` is now the **seconds left**, not a flag (still truthy, so every `!G.changeover` guard reads the same), and it counts itself down near the top of `update()`, calling `voice('time')` when it hits zero. It no longer rides the generic `G.overT` expiry — anything that overwrote `overT` mid- breather (a melee kill taking a game, say) used to make the umpire call time on a pause that was never a changeover. Verified with a `fetch` spy on `assets/audio/time.m4a`: an unrelated `overT` expiry at t=0.4 s says nothing and leaves 4.6 s on the breather; the line fires exactly once, when the breather's own clock runs out. ### 5. The transition tutorial illustration confuses people — was MEDIUM `assets/x_howto.png` regenerated (Gemini 3 Pro, edit mode with the old sheet as `--input`, then `assets/_key.py … 240`). The curved arrow and thumbprint are gone; it is now a press ring at the top, a straight vertical dashed track, two chevrons pointing DOWN, and a fingertip at the bottom — with the yellow shot arrow flying the opposite way to the zombie, which is the inversion lesson. Aspect is unchanged (240×321 vs 240×322), so the modal still draws it at 90×120 in portrait. Checked at that drawn size, not at full resolution. ### 7. Aiming plays the toss animation; it should stay in the pretoss stance — was MEDIUM `update()`'s forced `sv.pose = 'serve'` for `phase === 'toss'` is now gated on `!G.aim`: while the slingshot is drawn the server holds the pretoss stance, and only `hitServe` puts him into the toss/smash. Verified: plain tap → pose `serve`; drawn bow → pose `idle`. ### 8. Show the current game number persistently — was LOW `GAME N` now sits permanently under the scoreboard, right-aligned to the board's width, on the same row as the NEXT pips and in the same dim tone (`render.js`, just before the pips block). The 1.1 s flash when a game arms is still there.