Files
splatter-tennis/PROJECT.md
T
wuwiandClaude Opus 5 d71a26831a The game gets a shell: it buzzes in your hand, never turns sideways, and a swipe walks you back
A Capacitor iOS project (`npm run ios`), and the first three bullets of LATER item 2 built. The web
build is untouched and stays primary: every line here degrades to a no-op in a browser.

- HAPTICS ride the sound map, as the plan said they would: six call sites, all of them places an
  sfx already fires — your racket contact, the serve strike, `killZombie` (the one funnel every
  weapon routes through), `explode`, `hurtPlayer`, `killPlayer`, the weapon swap. The restraint
  rule lives in `haptics.js`, not at the call sites: a minimum gap per kind, so a bomb that kills
  twelve bodies is felt twice and the machine gun is a metronome rather than one long rumble.
  Pinned by a test. Untested on hardware — the simulator has no Taptic Engine.
- The plugin is reached through the INJECTED bridge, `Capacitor.nativePromise('Haptics', ...)`.
  `Capacitor.Plugins.Haptics` reads like the zero-dependency answer and is silently empty without
  @capacitor/core's registerPlugin — it would have fallen through to the web path inside the app
  and looked exactly like a phone with haptics switched off. No package JS ships.
- PORTRAIT is enforced where it can be: one orientation in Info.plist, so the app never rotates.
  That retires landscape as a surface rather than fixing it (OPEN 6). The web half is best-effort —
  `orientation` in both manifests and one guarded `screen.orientation.lock()`.
- SWIPE LEFT GOES BACK. The gesture finds the screen's one visible chevron and clicks it, so the
  shape of the wizard stays in the handlers that already own it. Not passive on purpose: a swipe
  inside a poster card also fires that card's click, and preventDefault is what stops the screen
  answering forward in the frame the chevron walked it back.
- The app has no server behind it, so `config.API` and one branch in `net.js` point the shell at the
  live host by name. Consequence: the app always talks to PROD, never staging.

`node test.js` green (105) over four seeds; builds, launches and reaches the native plugin on the
iOS 26.5 simulator; swipe-back walks the whole wizard in the browser pane.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 17:00:36 +02:00

4223 lines
325 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PROJECT — Splatter Tennis
**This file is the only planning document** (renamed from `HANDOFF.md`, 2026-08-11 — it was never a
handoff, it is the project's living notebook). `PLAN.md` and `POLISH-PLAN.md` are deleted —
everything they specified is shipped, and what still matters from them is recorded below. For the
long history, `~/.claude/projects/.../memory/splatter-tennis-history.md` is auto-loaded; for the
detail of any individual change, read the git log.
**THE SUITE IS DETERMINISTIC ON PURPOSE (`Math.random` is seeded at the top of `test.js`).** It is a
Dockerfile build stage, so a red run blocks the deploy — which makes a test that fails one run in
five *worse than no gate*, because it trains you to re-run it. Two drill tests did exactly that and
the pipeline caught what sixty local runs had not: one asserted on a random batch composition, the
other on a SNAPSHOT of a population that legitimately empties (an eater crosses the whole court in
~24 s and despawns, so "no eaters on court at 40 s" is normal, not a failure). **Fuzz with
`SEED=1234 node test.js` whenever you touch anything probabilistic** — and beware the cost of one
fixed seed: a guarded test can silently take its early-out every run and quietly stop testing
anything, so if you add a `return` for an awkward deal, check the body still runs.
**`node test.js` must pass before any commit** — it drives the real game modules headless and pins
the state-machine contracts that have historically produced the show-stoppers. The Dockerfile runs
it as a build stage, so a red suite cannot deploy — but finding out from Coolify is the slow way.
Last updated 2026-08-20 (THE SHELL: a Capacitor iOS project, haptics on the existing sfx call
sites, portrait enforced in Info.plist, and swipe-left-to-go-back in the menus — the web build
unchanged and still primary. See "THE SHELL". Before that, same day: the friend across the net gets his OWN sheets — the player's kit, drawn
from the front, so his pick dyes the same garment yours does; the setup screen shows that front too.
See "THE FRIEND ACROSS THE NET WEARS YOUR KIT". Before that 2026-08-18: the store model in LATER revised: free download + €2.99 unlock-everything
IAP with the free-tier gates, superseding paid-upfront; before that 2026-08-15, two sessions: the
finetuning pass — gore opens in the gore, the menu theme
comes back, one world board per mode, and the barrel economy cut — over the batch below: six new
opponent families and an 18-rung ladder, the
tournament DRAW screen, three netplay fixes off Oliver's cellular report, the night court, and
camera flashes in the stand). **Start with "How to work on this", "OPEN — leftovers" and "LATER —
shipping it"** (all three just above the CLOSED sections) — the first tells you how Oliver's feedback
should be handled, the second is everything known to be outstanding in the game, the third is the
demo/store/campaign plan, which is decided but entirely unbuilt. All four playtest rounds are closed
below them, newest first. **Awaiting his next phone playtest.**
## What this is
SNES-style pseudo-3D tennis (Super Tennis reference) that transitions into a zombie splatter game
while keeping every tennis aesthetic (umpire voice, 15/30/40 scoring, ball boys, applause).
Zero-dependency vanilla JS, native ES modules, no build step. Mobile portrait one-handed play is
the primary target.
- **Run locally**: `preview_start` name `splatter-tennis` (`server.py` on :8641, `.claude/launch.json`).
It is a real server now, not `http.server` — **editing `server.py` needs a preview RESTART**, and a
stale Python process looks exactly like a broken feature (it cost a debugging round).
- **Deploy**: push to `git@gitea.wuwi.one:wuwi/splatter-tennis.git` (main) → Coolify webhook builds
the Dockerfile. Oliver tests on his phone at splatter-tennis.wuwi.one ~30s after
a push. **Bump `main.js?v=N` in index.html every time** or phones keep the old build.
The Dockerfile must also COPY any new top-level file (it currently ships `index.html`,
`manifest.webmanifest`, `server.py`, `src/`, `assets/` — `test.js` only feeds the test stage and
is deliberately not in the served image).
- **The image is `python:3.12-alpine` running `server.py`, no longer `nginx:alpine`.** The world
board needs one process that can WRITE, and nginx is not one; everything the nginx conf did here
(no-cache so a deploy lands on a plain reload, and `application/manifest+json`, without which
Chrome refuses to install the homescreen app) is a few lines in `server.py`. Standard library
only — no pip, no requirements file.
- **`/data` MUST be a persistent volume** (Coolify → the resource → Storages → add a volume mount
at `/data`). Scores live in `/data/scores.json`; with no volume that file sits in the container's
writable layer and **every deploy silently resets the leaderboard to empty**, which looks exactly
like the feature working until the first time you push. Verified both ways with a local rebuild.
Staging and prod are separate resources, so they get separate volumes and separate boards — which
is what you want, or staging tests would land on the real ladder.
- **Staging**: the `staging` branch deploys to its own subdomain (own Coolify resource, same repo,
branch `staging`, same Dockerfile). Test there first, then merge to `main` for the testteam.
The branch carries NO diff against main, so it fast-forwards and merges freely — keep it that way.
A subdomain, not a subfolder, so staging gets its own origin and can't stomp the prod localStorage.
Two Gitea webhooks, branch-filtered `main` and `staging`, both hitting the same Coolify URL (it
matches the resource by repo AND branch). A filtered hook's "Test Delivery" always looks dead —
Gitea fakes a push on the default branch and its own filter drops it. Push if you want to test.
- **Oliver edits files himself** (tuning values, sometimes sprites). ALWAYS Read current file state
before editing; never assume your last version is what is on disk.
## Module map (game code in `src/`; `index.html`, `server.py` and `test.js` at the root)
`config.js` TUNE (live T panel) + structural constants + `UI` palette · `state.js` the `G` object +
P/O/ball/score + `setupPoint`/`sendBoy` · `gameplay.js` ballistics/swings/serve/rules/AI/`update()` +
transition + `splatPoint` + ball-boy routines · `beats.js` **the beat machinery: every mode
transition (duel/horde/boss/changeover/drill/`proceedAfterClear`) — ONE MODE OWNS THE COURT, and
each `start*` enforces that itself; a new mode starts there and refuses there** · `drill.js` the
coaching drill's own clock, bodies and rules (its transitions live in beats.js like every other
mode's) · `zombies.js`
zombie/gore/particle state + `updateZombies` · `render.js` camera/palette-lerp/sprites/all drawing ·
`input.js` keys/touch/flick · `audio.js` sfx + recorded umpire (`assets/audio/*`, macOS
`say -v Daniel`) · `panel.js` the T panel · `main.js` boot, loop, scoreboard test-mode taps,
`window.T` test surface (the umpire-tap cheat is removed — the testteam found it;
`T.forceZombies()` is the console replacement) · `test.js` the headless regression suite
(`node test.js`, also a Dockerfile build stage — a red suite cannot deploy).
FIVE runtime-only import cycles (documented in headers): input↔gameplay, zombies↔gameplay,
state↔zombies (`sendBoy` reads `gore` to decide whether a mopping round is worth walking out for),
beats↔gameplay and beats↔zombies. All are safe for the same reason: the imported binding is only
ever touched inside a function at runtime, never during module evaluation.
## How the game flows
Tennis → win a game → **transition** (`G.mode='transition'`, timeline on `G.transT`) → **splatter**.
In splatter you serve at zombies; `ball.pow` ≥ `TUNE.zSplatQ` splatters, below that it staggers.
Tennis scoring keeps running. `TUNE.killsPerPoint` kills = one point; games/sets accumulate;
6 games = a set = a **horde** (racket auto-fires, finger steers you absolutely).
Odd games end in a **changeover** breather. A won game leaves the wave standing — you must
**clear it by hand** (`G.clearing`) before play advances (`proceedAfterClear`).
Every **10 games a CHAPTER BOSS** walks on (`G.bossFight`) — it clears the stage, blocks all spawns,
and volleys at you. Every 4 games a **rogue ball machine** is wheeled out. Both attack the second
damage channel: `shots[]` chip `G.hp`, which only a won duel heals. Contact is still instant death.
Weapons are **only ever won**, in the **sudden-death duel** (`G.interlude`) against the racket
zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixture he used to be). Getting caught = `G.mode='eaten'` = permadeath, and the run summary is the whole game.
## Architecture & invariants — know these before touching anything
- **`G.modal` is the only thing that stops the world.** `update()` early-returns on it (after
flash/card decay), so no zombie steps, no ball flight, no spawn timers — only `idleT` ticks for
the blink. `showModal`/`dismissModal` in gameplay.js; `drawModal` is the LAST thing render draws.
Dismissal must **swallow the gesture**: input.js `touchstart` returns on it, keydown returns on
it, and the `pointerdown` dismisser is registered **only on `pointer: fine`** — on touch,
pointerdown fires *before* touchstart, so a shared handler cleared the modal and let the
touchstart underneath serve. `G.seen.<key>` gates first-time-only modals.
**POLICY (2026-08-08, testgroup round): a modal may only fire on a guaranteed-calm court** (duel
intro, boss walk-on, post-duel healing, test mode, the horde's first-ever arrival). Anything that
can land on a live court announces as a CARD or a flash — the freeze itself is safe (measured:
a body moves 0.000 m behind a modal), but the stolen tap and the broken rhythm cost runs.
**`m.in` (0..1 over `TUNE.modalIn`) is the arcade entrance** and the ONE thing that animates behind
the freeze — it is ticked inside that same early-return, so it advances while nothing else does.
Icon and title fly in from the right (with a two-ghost speed streak), body lines cascade from the
left, all on `easeOutBack`; every x is `Math.round`ed, because a fractional x on a nearest-neighbour
scaled image re-snaps its pixel columns (the boiling that once read as HUD flicker). A modal with no
`in` field draws fully landed, so older/test call sites still work.
**`m.hold` is an optional self-dismiss clock** (seconds, ticked only once `m.in` has landed): the
modal announces, holds the frozen court, and lets go by itself. The chapter-boss walk-on is the
case it exists for — an announcement whose job is to PREPARE the player, where demanding a tap to
start a fight he did not ask for would be the control steal the popup policy exists to prevent.
A tap still skips it. Every other modal has no `hold` and waits for the player, as before.
While `m.in < 1`, `dismissModal` **returns true without dismissing** — it swallows the gesture but
won't let a panic tap that was already mid-air skip a popup nobody has read. Returning false there
would drop that tap through into a serve instead.
- **The difficulty director lives in `updateZombies`'s spawn block + `spawnZombie` + `pickVariant`,
and every dial is keyed on `G.runGames`.** It is the thing that broke first: as shipped, ALL of it
bottomed out by about game 12 (body cap pinned at 14, spawn gap floored at 1.0 s, variant mix frozen
at exactly 0.42 advanced, shamble speed maxed after 105 *seconds* of splatter, boss stuck at 3 hp,
every horde identical). Oliver played 50+ games and reported the plateau; games 12-50 were
arithmetically the same game. All six now keep climbing — see the TUNE block marked
THE DIFFICULTY DIRECTOR. Calibration rule: the curve must **track the old values through ~game 12**
and only then extend, or you have silently nerfed the part of the game that was already fine.
The raw dials do run out around game 25 by design; bosses and ranged attacks (steps 4-5) are what
carry escalation past that, not more bodies.
- **`woundStage(zb, n)` in config.js is the ONE rule for how damage READS.** 0 = unhurt, 1..n = the
skins, spread evenly over `hpMax`. There used to be three rules: the brute's hardcoded stage array
indexed by absolute hp, a single skin behind `'abd'.includes(...)`, and the boss's fraction-of-hpMax.
The boss's rule reproduces the other two EXACTLY (5 hp over 4 skins is one skin per hit; 2 hp over
1 skin is the shambler) and keeps working when hp is not one-per-skin — which the ball boy (3 hp,
2 skins), bouncer (6/3) and roller man (9/4) all need. So a new multi-hit body is now one row in
`config.ZWOUND` (healthiest first, `…b` stride twin looked up automatically) and NO code.
`bleed()`'s anchors are read through the same call and are deliberately the same length as that
variant's ZWOUND row, so the blood and the drawing it comes out of cannot drift apart — they used
to be written by hand against different constants. **'r' and 'B' keep their own skins** (the boss's
on its design row) but go through the same `woundStage`.
- **A chapter boss's MOVEMENT is data on its `BOSS_ART` row** (`move`, plus optional `hpMul`,
`speedMul`, `volley`, `gap`, `spread`, `cycle`, `wind`, `shot`), and `bossAI` in zombies.js is the
only place it moves. Every field is optional, so a row carrying nothing but art behaves exactly as a
boss did before there were any. Three things that are load-bearing and were each a bug first:
**(a)** the generic forward step at the top of the body loop SKIPS `'B'` — bossAI owns both axes,
and leaving it in advanced the boss twice per frame; **(b)** the stride steps off `zb.dist`
(ground covered on both axes), not `zb.z`, or a boss strafing sideways slides with its feet frozen;
**(c)** a boss holds a z BAND (`bossZMin..bossZMax`) except while charging — uncapped, a fight ended
by the boss simply arriving after about a minute, and every extra hp made that MORE likely, not less.
**No style may rely on the player moving to create its own motion**: in splatter he is rooted in
`pretoss` for nearly all of a boss fight, so "close on the player's x" arrives, stops, and is static
for the rest of the fight. Every style moves under its own power and only BIASES toward him.
- **`wound(zb, amount, fx)` in zombies.js is the ONE place damage is applied to a multi-hit body.**
Fractional amounts are legal (a pellet chips `TUNE.dmgPellet` = 0.34 of a point); they bank on
`zb.chip` and only WHOLE points come off `zb.hp`, because the wound skins and `bleed()` anchors
switch on hp thresholds and a fractional hp would never match one. It returns true when the body
should die and the CALLER kills it — `splatter()` when the kill should get the multi-kill fanfare,
`killZombie()` for a quiet one — after which the caller must stop touching `zb`, because
`killZombie` splices the array. A boss with 20 hp therefore needs no new damage code at all.
- **Boss damage state is read as a FRACTION of `zb.hpMax`, never against literal hp numbers.**
`spawnRacketZombie` scales hp with `TUNE.racketHpPerGame` and stores `hpMax`. Two places switch on
it and they MUST stay in step: `drawZombie`'s skins (`<= 0.34` headless `z_cw2`, `<= 0.67` torn-arm
`z_cw1`) and `bleed()`'s wound anchors. The old hardcoded `hp === 2` left a scaled boss looking
pristine until the killing blow.
- **The racket boss has NO health bar, deliberately.** It was the only floating UI on a body anywhere,
it was on screen for nearly the whole run (a boss respawns every game you win), and players did not
know what it meant. The wound skins already encode the same information in the language every other
multi-hit enemy uses. A one-per-run modal (`G.seen.racket`, fired from `spawnRacketZombie`) explains
what he *is* instead. Do not re-add a bar; give a new boss more wound stages.
- **There are TWO damage channels and they must never be merged.** Contact with a body is still
instant permadeath (`killPlayer`, `G.mode='eaten'`); ranged attacks chip `G.hp` (`hurtPlayer`).
Oliver settled this explicitly — two threats that demand two different responses is the depth, and
making contact chip instead of kill would lose the dread the whole game runs on. `killPlayer()` is
the single death path for BOTH causes (extracted for exactly that reason: the run summary, the
best-run localStorage write and the death animation exist once). `TUNE.hurtGrace` is what stops a
three-shot volley deleting you in one frame.
- **The player's health is drawn ON HIM and nowhere else.** No bar, no meter, no HUD number — same
rule that removed the boss's bar. `drawPlayerWounds` paints procedural spatter at FIXED anchors
(fixed so it cannot crawl frame to frame), `bleedPlayer()` adds the live trickle, and `G.hurtFlash`
washes the screen edges inward on impact. Blood is never painted into the player sprite: that is a
settled feel decision, which is why this is an overlay and not a set of bloodied-kit PNGs.
- **`shots[]` in zombies.js is the HOSTILE ball array** — the ball machine's shots and the chapter
boss's volleys. Deliberately separate from `ball` (there is only ever one of those and it is yours)
and from `extras` (also yours, and they kill zombies). Three outcomes, in this order: a swing inside
the volley window RETURNS it — as an `extras` clone at `TUNE.parryPow`, so "a parry is a real
weapon" costs nothing to build; `meleeSwipe` merely bats it away; reaching you unanswered costs hp.
**The swish path is not optional politeness** — in `pretoss` the player is rooted and cannot swing
at all, so without it a machine winding up while you lined up a serve was an unanswerable hit.
- **`isBoss(zb)`** (`'r'` or `'B'`) marks bodies that take `wound()` damage from everything instead of
being deleted by it. The brute `'g'` is deliberately NOT one: melee/bomb/fire one-shotting a brute
is a settled "power exception". Every weapon path checks it — miss one and that weapon silently
no-ops or one-shots a 34 hp boss. This has already been the bug twice.
- **The chapter boss scores NOTHING when it dies.** Its reward is the chapter card plus the parked
game beat resuming. Letting it bank a kill meant `splatPoint`'s game-over branch and
`endBossFight` both decided "what happens next" in the same frame and clobbered each other.
- **`blocksClear`** decides which bodies gate the hand-clear after a won game: NOT the racket zombie
(it keeps its residence) and NOT the ball machine (furniture — a standing threat you deal with when
you choose). Making the machine gate progression turned "decide what to prioritise" into a chore
every four games.
- **The parry is bound to the TAP, in every phase, and that is load-bearing.** `keyPressed` checks
`shots` for anything within `TUNE.parryArm` FIRST and turns the tap into a swing if so. Before that,
the parry sat behind the `phase === 'rally'` branch at the bottom of `keyPressed` — and splatter is
in `pretoss` nearly all the time, so a tap aimed at an incoming shot tossed the serve ball instead
and the return was unreachable in practice. Oliver could not land one in a whole session.
`TUNE.parryReach`/`parryWindow` are deliberately much more generous than a normal volley (2.4 m /
0.34 s), because this is a phone, one thumb, against something aimed at you. The fail cases still
exist and are what keep it a timing skill: tap before the shot is inside `parryArm`, tap after it
has passed, or don't tap. **`parryWindow` must stay <= `swingWindup + swingWindow * VOLLEY.window`**
(0.348 s) or update()'s swing clock expires before the window closes.
`meleeSwipe` deliberately does NOT touch `shots`: it runs first in `keyPressed`, so it used to eat
the very shot the parry was about to return.
- **`zb.hitT` (`TUNE.bossHitCool`) stops a LINGERING ball multi-wounding a boss.** A flame ball has
full pierce — it is not consumed on contact, so it can cut a lane — but a boss is not removed when
hit, so the same ball sat inside its radius and re-wounded it *every frame*. One flame ball killed a
16 hp chapter boss. Flame now also spends itself (`lastHit = 'X'`) on a boss specifically. Any new
weapon that is not consumed on contact must set `hitT` too.
- **No blood is painted onto the player.** Oliver: *"the player blood looks odd, appears as a painted
layer on top"*. His health reads as `bleedPlayer()`'s live drip plus the screen-edge wash on impact,
and nothing else. This is the same settled rule that governs the zombies — blood is never painted
into a sprite — and a `drawPlayerWounds` overlay broke it.
- **Boss designs are DATA: `BOSS_ART` in config.js, one row per chapter, cycling.** Each row is
`{ name, walk: [2], wounds: [...], throws: [...] }`; `spawnChapterBoss` stamps the row onto the body
as `zb.art` and `drawZombie` reads only that. Damage picks a wound stage as a fraction of `hpMax`
spread over however many stages the design HAS, and `throws` is index-clamped, so a design with
fewer throw frames than wound stages just reuses its last one. Adding a boss is a sheet plus a row —
never a change to drawZombie. Missing/unloaded sprites degrade to design 1 rather than vanishing,
which is convenient and also means **a wiring bug looks exactly like working code** (see pipeline
note 7).
- **Sprites are 2x (`SPRITE_H` 224, `_slice.py` `IDLE_TARGET_H` 224) and EVERY scale-critical sprite
must share that scale.** `RES` is 3, so the player at the baseline rasterises into ~170 DEVICE
pixels — a 112px source was being UPSCALED 1.5x, which is why near bodies looked soft. Sprites that
go through `drawChar`, `drawBoy`, `drawMachine` or the wound-stage `h *= img.height / SPRITE_H` rule
are all measured against `SPRITE_H`, so a sprite left at the old scale renders at HALF height.
That is why the remaster 2x-upscaled the ~29 frames it could not re-slice rather than leaving them.
Sprites drawn through plain `drawStatic(name, x, z, hMeters)` only care about ASPECT, so their
resolution is free.
**Heights above 224 are deliberate, not bugs**: a smash or toss pose SHOULD draw taller than idle
(`p_smash` is 322), and that ratio is exactly what `SPRITE_H` exists to express.
- **The umpire's asides are SITUATIONAL, never a timer pool.** `aside(name)` in gameplay.js is the only
entry point and enforces one shared cooldown (`TUNE.asideEvery`), so several triggers in the same
moment still yield one remark. The first version drew from a bag on a timer and Oliver's verdict was
"too much and not situation aware.. they seem random". Each line is now tied to the thing that
justifies it — a new variant appearing, the court filling up, one crossing your baseline, a game lost,
a tin delivered, a horde, a changeover, a machine, a boss, the trainer, taking a hit.
- **The portrait viewport ADAPTS to the device, and `TUNE.horizon` must ride with it.** `fitViewport`
picks `h = 270 * clamp(innerHeight/innerWidth, 1.6, 2.3)` — a fixed 270x480 is 9:16 while a modern
iPhone is ~0.46, so width bound the fit and ~150 CSS px of screen went unused. `proj()` places the
court in ABSOLUTE logical px off `horizon`, and the rack/meter hang off `H - ...`, so extra height
lands entirely in the apron between them: leaving `horizon` fixed just grew a dead green band.
It is computed as `45 + (h - 480) * 0.72` for that reason. `RES` is 4 (was 3) so an iPhone's ~1125
device px across is nearly 1:1 with our 1080 — **`RES` is the first knob to turn down if the phone
ever stutters**, it is the whole fill-rate budget.
Note `#hint` (the desktop key legend) is already `display:none` on `pointer: coarse`; it only
overlaps the rack in the desktop test pane, which is not a real bug.
- **The swish cooldown scales with how swarmed you are** (`meleeCooldown()` in gameplay.js):
`meleeCool / (1 + (near-1) * meleeCrowd)`, floored at `meleeCoolMin`, counting bodies within
`meleeCrowdR`. 1 body 1.00 s, 4 → 0.47, 8 → 0.27, 14+ → 0.22. Oliver died on every game-20 horde
because the wave grows with `hordeGrow` while the panic button was a flat 1 s; he chose scaling the
melee over shrinking the horde. **Measure the crowd BEFORE calling `meleeSwipe`** — it kills
everything in reach, so counting after counts the survivors and the cooldown barely moves.
- **`render()` clears the whole frame to `pal.out` first.** There was NO clear for a long time: the
stands cover the top and the out-area quad covers the middle, so it looked complete — but the quad
only reached z=30 and BELOW where that projects the canvas was simply transparent. On a phone that
read as a duller band across the bottom of the screen. The quad now runs to z=60 as well, so the
perspective floor reaches the bottom on any aspect ratio. If you ever add a new orientation or
stretch the canvas further, check the bottom rows are opaque — sample `getImageData` alpha, the
colour alone will not tell you, because the page behind is deliberately the same green.
**Do NOT try to cover the bottom by extending the out-area quad instead.** `proj()` divides by
`(camZ - z)`, so as z approaches `TUNE.camZ` (48) the scale explodes and past it goes NEGATIVE —
a quad taken to z=60 folded its far edge back above the screen as an inverted triangle. The clear
is what covers the screen; the quad only has to cover the court.
- **The page gradient goes on `document.documentElement`, and `html` needs a real height.** Two
separate CSS traps, both hit: the ROOT element's background is what paints the viewport, so setting
it on `body` while `html` also has a background painted nothing; and a GRADIENT is laid out in the
root's own box, which collapses to zero when the canvas is `position: fixed`, so `html { height:
100dvh }` is load-bearing rather than cosmetic.
- **The page background behind the canvas tracks the live court apron** (`pageBackground` in
render.js). It is visible in exactly two places: the strip above the canvas under the status bar,
and the slivers a phone's ROUNDED DISPLAY CORNERS clip out of the canvas — a flat dark body drew a
visible dark notch where the bottom corners curve in. It is a two-stop gradient: `#10141c` down to
the top inset, then `pal.out` for the rest. **`pal.out` must be read live**, because it lerps
between the tennis and splatter palettes and any fixed hex would only match in one mode. Written in
resolved px rather than `env()` so the declaration can never be invalid and drop the background,
and only assigned when the string actually changes.
- **The safe area is handled ASYMMETRICALLY, on purpose.** TOP: index.html insets the canvas element
below the status bar, because that is opaque UI the scoreboard must clear - `SAFE.top` stays 0, and
adding a HUD offset there as well subtracts the notch twice. BOTTOM: the canvas deliberately runs to
the PHYSICAL bottom edge so the court art fills the screen (the home indicator is a slim translucent
bar, not opaque UI); only the interactive RACK and the draw meter lift clear of it, via
`SAFE.bottom`, which `measureSafe` reads from the hidden `#safe` probe (env() is CSS-only).
Reserving the bottom inset on the element instead just left a dead band.
- **The canvas is STRETCHED by CSS (100vw x 100dvh minus insets) and `fitViewport` derives the logical
grid from the box it was actually given** — `cv.clientWidth/clientHeight`, never `innerWidth/innerHeight`.
Sizing it the other way round (compute a scale, then set explicit px) laid it out against whatever
`innerHeight` reported at load, before iOS Safari's toolbars had settled, and it stayed a strip short
of the bottom forever after. `100dvh` is what tracks those toolbars.
- **The canvas is deliberately FULL-BLEED under the notch and home indicator; the HUD is what respects
the safe area.** `viewport-fit=cover` is set, and the stands running to the screen edges look right —
but an iPhone's clock was landing directly on the scoreboard. A hidden `#safe` probe div carries
`env(safe-area-inset-*)` (env() is only readable from CSS), `measureSafe()` converts it to LOGICAL
canvas px using the same scale `fitViewport` just applied, and `SAFE.top`/`SAFE.bottom` shift the
scoreboard down and lift the rack + `METER_Y` up. Anything new that anchors to the very top or very
bottom of the screen must offset by these too — and the rack's hit test shares `rackGeom`, so taps
follow automatically.
- **`UI` in config.js is the accent palette** (hot/warm/pick/cream/dim/bad/ball). Every HUD colour
goes through it. Gore reds are deliberately NOT in it — the splatter stays loud.
- **`F(size, bold)` in render.js builds every canvas font string.** Bare `monospace` resolves to
Courier, a slab SERIF — that was the "generic serif" bug, not taste. Never hand-write `ctx.font`.
Widest string checked fits 270px portrait with ~10px spare.
- **Weapons are only ever WON.** `deliverTin()` has exactly one caller: `resolveInterlude('P')`.
The schedule (`TUNE.duelFirst`/`duelEvery`, `G.duelDue`) calls the **duel**, fired from
`proceedAfterClear()` (beats.js) — the one beat where the court is guaranteed empty. The game beat it
interrupts is parked in `G.afterDuel` and resumed when the duel resolves.
- **Anything that blocks serving.** If a "can't serve" report ever comes in, check these first —
each is an early return in `keyPressed`: `G.mgT`, `G.horde`, `G.changeover`, `G.interlude.introT`,
`G.modal`. `G.mgT` in particular must keep counting down in EVERY phase (it used to tick only
during `rally`, so a duel calling `setupPoint()` stranded it above zero forever = permanent lock).
- **Animation frames step off DISTANCE COVERED, never elapsed time** — `boy.dist` for ball boys,
`zb.z / TUNE.bruteStride` for brutes. Time-driven cycles moonwalk the moment speed and frame rate
disagree. Sprite heights follow the PNG (`img.height / SPRITE_H`) because `_slice.py`
common-scales a sheet; a wound stage's height must ALWAYS come from its A frame or it pops.
- **Fire chains.** `zb.burnT`/`burnD`; `ignite()` is read-only over `zombies[]` so it is safe inside
a kill loop; `burnTick()` returns true when the fire killed the body and the caller MUST
`continue`. The flame ball also ignites what it FLIES PAST (`TUNE.burnBallR`) — without that its
full pierce killed its own lane outright and nothing was left to catch. Flames are drawn
procedurally (`drawFlames`), no sprite sheet.
- **Any loop over `zombies[]` whose body can kill** can cascade into a sweep and gut the array
mid-iteration — keep the `if (!zb) continue` guards and iterate backwards.
- **The ball boy is ONE slot.** `sendBoy` never replaces a boy already on court (a kill streak used
to teleport him back to the wing several times a second); new work is appended via `jobs`.
`mopRound` walks him to a stain, holds him there for `TUNE.boyMopDwell` while the push cycle
animates in place, then moves him on. Three sheets: `x_boymop` (stopped, working),
`x_boycarry` (walking with the mop shouldered), `x_boyrun` (tennis-mode fetch).
- **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
- **Far bodies draw off a flattened scale curve; the camera holds still.** `TUNE.farK` 0.25
(render.js `farB`): the court projects true, bodies grow toward the far baseline (~20% at the
back), anchored so the near player never changes. Oliver: *"new perspective is really good —
helped a ton."* The ball-follow lean built alongside it (`farCamY`/`farCamZ`) was vetoed the
same day — *"the camera movement is distracting"* — and sits at 0 like the aimCam pair: every
camera-motion experiment on this game has now lost to a still camera.
- **Two-stage serve**: touch = reticle, drag = aim, release = fire, drag back under `pullMin` =
cancel. The pull's DIRECTION picks the lane, its LENGTH is power — deliberately decoupled so a
far shambler never costs a longer drag than the screen has.
- **The manual wave clear** after a won game (`G.clearing`). Oliver was emphatic.
- **Thin, dynamic blood.** Open wounds gush live particles (`bleed()`); blood is never painted into
a sprite. Gore piles/stains are the loud part; keep the trickle sparse.
- **One gesture means one thing** on touch. No mode where a drag silently becomes something else.
- **Volleys**: pre-bounce contact gets reach ×1.4 / window ×1.8 (`VOLLEY`). A RISING ball is
hittable at any height; only a dropping ball below `CONTACT.minY` is past you.
- **Aim magnet**: `flickTarget` picks the best-ALIGNED body, with `TUNE.aimTie` of slack in which a
nearer one still wins, so a straight-ahead lane still eats the nearest body first.
`aimTie: 1` restores the old "nearest along the lane always wins" for A/B.
- `TUNE.fps` 60 — 30 was vetoed on feel.
- **The player only walks during `rally`.** Never between points. Letting him walk during the 'over'
beats and `pretoss` was shipped once and vetoed immediately: *"I basically can never walk around
freely"* — so suddenly being able to, only between points, reads as the game breaking rather than
as a fix. Frozen-with-danger-approaching is fixed by holding the DEAD instead (`held` in
`updateZombies`), never by handing back movement.
- **The player must always be ON SCREEN.** `P_BOUNDS.x` is 6.5 m but portrait only shows about
±4 m either side of the centre line at the baseline, so the walk bound alone let him leave the
frame entirely. `xLimit()` in gameplay — the camera limit, floored at the sideline so every legal
ball stays catchable. The walk and the sidestep share it.
- **The auto-assist recovers in DEPTH ONLY in splatter — it must never undo a sidestep.** The
recovery target used to be `HOME.P`, x included, so firing walked you back toward the centre line:
measured, a step out to 1.94 m was dragged to 0.77 m over one ball's flight, i.e. most of a dodge
you had spent a cooldown on, handed back for free. That is the "welded in place" complaint the
sidestep exists to answer, arriving slowly instead of all at once. **Tennis keeps the full
recovery** — recovering to centre is the real sport's footwork and there is no sidestep there to
protect. (Oliver: *"the dodge swipe should not reset to the middle when firing"*.)
- **The sidestep is how you move outside `rally`, and it is not a walk.** One committed step
(`TUNE.dodgeDist`) on a cooldown, carrying `TUNE.dodgeIFrame` seconds that beat BOTH damage
channels — the chip damage in `hurtPlayer` and the instant-death contact test. It exists because a
boss fight is spent in `pretoss`, so the rule above left you welded to the spot for the whole
fight and Oliver died at every chapter boss without ever being able to move.
- **Which way you leave the deadzone IS the gesture.** Decided once, at the `DEAD` crossing, and
never revisited for that touch: down-ish = the slingshot, sideways = the sidestep. This is how the
new gesture stays inside "one gesture means one thing" — there is no timing window and no hidden
mode, only the direction you actually moved. The finger-steer modes (horde, burst) are untouched:
`stick.aimable` is false there, so the drag is still the absolute steer it always was.
- **Two type registers, and the register IS the message's category.** Silkscreen for the HUD, the
rack, the control hints and `RESUMING IN` — things you read at leisure. Bangers (`B()` in render)
for `SET`, `CHAPTER`, `EPIC!`, the praise words and every free-floating flash — things shouted at
you. EVERY floating string goes through `shadowText`, both faces, because none of them has a panel
and all of them have to survive the crowd, sunlit clay and blood behind them. The scoreboard and
the rack are the two exceptions: they sit on their own dark panels, and a 1 px shadow under 6-8 px
type on a flat dark fill only smears it.
- **Praise is a narrow window, not a ceiling.** `pullQuality` SATURATES: `pullFrac` clamps at
`pullFull`, so every pull past 130 px is exactly quality 1.0 and the old `quality > 0.999` test
praised nearly every serve — *"it displays perfect a lot even when nothing special happened"*.
`hitServe` takes `perfect` as its own argument now: the toss ritual still earns it on the 45 ms
`perfectMs` window, the slingshot earns it by STOPPING in `pullFull..pullFull*perfectBand`. The
draw clicks brighter when you enter that band and goes quiet when you overshoot — without that
audible target it would be invisible skill. The word itself rotates through `PRAISE` (cycled, not
random: the same word twice running reads as a stuck HUD element, not as praise) and sits behind
`TUNE.praiseEvery`, the same shared-cooldown model the umpire's asides use — you LEARN to hit the
band, and then a per-serve word is noise again whatever it says. The click is the feedback for a
good pull; the word is garnish.
- **The free-floating slot is 18px, not card-sized.** Half of what lands in it (the praise words,
`RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud
FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone.
## ZOMBIE TENNIS — the mode, end to end (folded from HANDOFF-MATCH.md, 2026-08-17)
`HANDOFF-MATCH.md` was a temporary file written when a session ran out of context mid-slice. The
slice is finished; this is what survives it. The reasoning behind the numbers is in the drill/match
session logs (passes 13-23) further down — this section is the contract, not the diary.
**What it is.** Real tennis against the racket zombie. One game per opponent, tap-and-timing
controls, the deuce/ad serve positions, and **the score IS the body**: each point you win takes a
quarter of its pool and walks it through its wound stages, and the game-winning point fells it so a
fresh opponent shambles on. Its points draw your blood (`matchPlayerHp` 8, `matchHeal` 2 per game
won). **The measure is OPPONENTS FELLED** — in play (`OPPONENT n · m FELLED` under the board) and on
the death screen. Entry: the setup screen's three modes (`config.MODES`), or the back room's MATCH
TEST, which still works and is the fastest way in for a test.
It runs on a PERMANENT interlude. `G.interlude` is what makes serving tennis-like, hides the rack
and wakes `racketAI`; the only structural change is `resolveInterlude` feeding `matchPoint` instead
of ending anything. **So `G.mode` is `'splatter'` here, never `'tennis'`** — every mode gate written
as `G.mode === 'tennis'` silently excludes the match, and that has been the shape of four separate
bugs (the umpire, the boy, the serve, the flick lesson). `rallyFeel()` is the honest test for "a
real rally mode": classic AND the match, and NOT gore's one-game prologue.
**SCORING IS BLOOD-ONLY** (Oliver's pick of three). The run is one long bleed across opponents;
losing a game costs nothing but the blood already spent, and heals are how you last. *Model B —
tennis-true, a lost game ends the run — was rejected for now and is still worth one playtest; it
might be the mode's real personality.* Context worth keeping: Oliver believed a lost match killed
him ("i really die losing one match"). It did not — he was bleeding out during deuce wars. **That
misreading was itself the bug**: blood got a pip readout beside the board because the vignette says
"worse" and never says "how many".
**The ladder escalates in `racketAI`, not in hp** — the pool is flat. Four dials, each climbing per
rung: speed (`matchSpeedBase`/`Per`), whiff (`matchFlub`/`FlubKeep`), width (`matchWideBase`/
`WidePer`), pace (`matchOppPace`/`PacePer`). Player side: `matchAssist`, `matchReach`.
**THE CARD — ten bodies, one list read twice.** `config.OPPONENTS` is the narrative AND the
difficulty curve: the further you get, the further up the club's hierarchy the corpse across the net
used to be. SHAMBLER, BALL BOY, GROUNDSKEEPER, STEWARD, RUNNER, LINE JUDGE, CLUB PRO, THE BULK,
DIRECTOR, CHAMPION — each with a name, a `short` for the board, a line, a sprite family and a
personality that is DIALS (pace, width, flub, speed scalings on the numbers `racketAI` already
reads). **The ramp is asserted, not assumed**: a test walks all ten and requires the whiff rate to
fall every rung, the names to fit the board's 8-char slot, and speed never to step meaningfully
backwards. Speed may ZIGZAG by character (5 is the RUNNER, 6 paints lines instead of sprinting); the
whiff rate is the dial the ramp actually rides on.
**THE WOUNDS PLAY** (2026-08-17). `config.matchWound` reads the same `racketStage` the SKINS are
picked with and returns `{reach, flub}`, which `racketAI` spends on its contact radius and its whiff
rate: a one-armed opponent covers less, a headless one misses more. Deliberately SMALL — 6% reach
and 17.5% whiff PER STAGE (Oliver: "not too pronounced") — texture under the ladder, never a second
difficulty curve, and a test pins that ceiling. Unhurt returns exactly `{1, 1}` and the whole thing
is gated on `G.match`, which is what keeps the gore duel's racket zombie untouched. Note the
arithmetic: a quarter off a full pool is 0.75, so **point one crosses no stage** — the dials move on
points two and three, in step with the art.
**THE FALL.** Point won → the body COLLAPSES → drags itself up minus the limb (`beats.tickMatchFall`,
on its OWN clock beside the horde's warning — a beat that moves the phase under a latch strands it,
the mgT lock again). The 'over' beat it also sets is deliberately longer than the fall, so it cannot
expire into `setupPoint()` mid-collapse and hand you a serve over a body on the ground. The
contract: **the three collapse frames wear the stage it STILL WAS, the rise wears the stage it has
BECOME**, and it only plays when the stage actually moved.
**THE FLICK IS THE MODE'S ONE ACT OF SKILL, and it used to lose the point.** Oliver: "8/10 times the
ball lands out". It was arithmetic. The singles line is 4.115 m; an aimed shot targeted a flat width
and THEN added `err()` on top — up to ±`aimError` at quality 0 — so a flicked return landed as wide
as 5.7 m, while an UNAIMED return (target 0 + the same scatter) was always in. **Aiming was the thing
that lost the point.** `TUNE.flickReach` scales the aim width by the same quality the scatter shrinks
with, so the two can never sum past the line, and a test asserts that sum across the whole quality
range rather than sampling rallies. **Not named `aimReach`** — that key already exists further down
TUNE (the slingshot's aim ray) and the duplicate silently won, which a test caught. The placement
ring carries `q` and reads as the flick's receipt: tight and hot when your timing earned the full
width, wide and dim when you barely got a racket on it.
**...and it is TAUGHT** (2026-08-17), because the ring is a receipt, not a lesson — you only get one
if you already knew to swipe. The ghost hand mimes it on the apron in the SERVE STANCE, in the very
demo the slingshot lesson plays, armed by a run of returns that left with no aim on them
(`G.flatStreak` / `flatCount`, `TUNE.flickHintAfter`) and disarmed by the first aimed one. **A
gesture is read in a pause, not in a point** (Oliver: "it is displayed mid-play.. please rather show
it in one of the pauses") — the first cut rode the incoming ball, at the moment the player has the
least attention to spare. Touch only, rally modes only, and the tap lesson wins if both qualify:
teaching placement to someone who cannot reach the ball is the wrong lesson.
**...and its LENGTH is its strength** (2026-08-18). Oliver: *"the flick to aim sometimes is applied
very strong, sometimes it barely does anything. this leads to a lack of agency, the player can't
really tell if there was skill or luck."* He was right and it was the gesture reader, not the feel:
the rally flick was quantised to THREE LANES (`ax`, a swipe past 0.38 of its own length sideways),
so two neighbouring gestures differed by the whole court and nothing lived in between. `gameplay.
swayOf` reads the swipe's length as a continuous 0..1 (`TUNE.flickMin`..`flickFull`, px) exactly as
the SERVE already did, and `doHit` spends that instead of `ax`. Keys have no length, so they keep
the full lane, and the AI's aim (no `mag`) is untouched.
**...and the out was tightened once more** (2026-08-18, *"make it a little harder to hit the out"*):
`flickReach` 0.62 -> 0.52, halving the worst-case rate from ~15% to ~7% and pinned by the test. The
shape is unchanged — a perfect flick still never sails, a badly timed full swipe still can — but a
mistimed one now lands NEARER THE MIDDLE rather than nearer the line, which is the only half of that
trade a player who cannot see his own timing can do anything about. **`flickReach` is the out dial**;
`aimError` is shared with the opponent's returns and with every aimed shot in the gore modes.
**This is also what hands the OUT back to the player.** Oliver: *"the number one reason i die is
that i myself keep firing balls into the out."* Going out was never his choice — every aimed shot
took the FULL width and then `err()` on top, so the dice decided. Width follows the swipe now, so
the risk band has a floor the player can stop short of: **below `(line - aimError) / (flickWidth *
flickReach)` ≈ 0.7 of a full swipe, the worst mistiming in the game cannot put the ball out**, and
above it the old risk curve is untouched. The tested contract from the pass before this one still
holds — a perfectly timed flick never sails, a badly timed full one can.
**THE AUTO-RUN OWNS THE LEGS, so the drag does not** (2026-08-18, Oliver: *"could it be that when
playing touch on mobile zombie tennis and tennis i am still able to manually move by dragging? in
autorun this must be disabled"* — it could, and it was). It was not a harmless second option: a
manual axis SUPPRESSES the assist (the `!mx && !mz` gate in the movement block), so holding a drag
traded a full-speed run to the intercept point for a `0.35x` shuffle aimed at wherever the thumb
went rather than at where the ball will be. Walking was strictly worse than standing still, which is
most of *"i tend to not be able to reach the balls"*. `input.js` stops writing `touchAxis` under
`rallyFeel()`; the drag keeps its one rally meaning (release it quickly and it is the aimed swing),
keys stay a deliberate desktop override, and GORE keeps its drag-walk — there the assist recovers
depth only and holding your own artillery lane is the point.
**THE AIM IS DRAWN WHILE THE THUMB IS STILL MOVING.** Oliver asked to *"visualize the strength of my
flick at flick-time"*, and he was asking for the half the placement ring cannot give: the ring is a
RECEIPT, it arrives with the ball already gone. `G.swipe` (set by input.js during the drag, cleared
on release) marks where the ball is being ASKED to land.
**It is a cloud, not a diagram.** The first cut was a ring on a ray from the player's feet and it
was rejected on sight (*"pretty bare bones... the line from the player to the indicator must go
away, it is distracting"*): a line drawn across the court is a HUD element laid over the game.
What is there now is a soft stain on the ground in the same procedural register as the dust and the
blood — it GROWS with the length of the swipe and it WARMS as that ask approaches the width no
timing can keep in. Three things were learned making it read, all of them the hard way:
* **metres, not pixels** — sized off `p.s` so perspective shrinks it with distance. A pixel radius
looked fine at the baseline and was an 8-px smudge at the far corner, which is where it lives.
* **a stain, not a glow** — `globalCompositeOperation = 'lighter'` turned the red end YELLOW over
green grass, and the hue is the entire message.
* **the ramp is pale lime -> gold -> red, not green -> red** — green on green is invisible, so the
obvious ramp showed nothing across the whole safe half of its range. The cool end carries its
contrast in LIGHTNESS, which is what makes it read on grass, clay and slab alike.
The threshold is not a flip: the colour is already gold before you reach it, so "too hard" is
something you watch coming. **It is OFF as of its first playtest** (*"hide the indicator for now"*)
— `TUNE.aimCloud` gates the whole block and the tuning panel can flip it back live. Kept rather than
deleted: it is the only thing that shows the gesture's strength before the ball leaves, and the
lesson it teaches has no other teacher. Shown from `TUNE.flickMin` for as long as the drag lives —
since the crossing-fired flick (below) the vector stays live until contact, so there is no release
deadline for it to lie past. Rally modes only (`rallyFeel`); gore's flick free-aims at a body, not
at a lane. The placement ring
turns `UI.bad` when the shot really is out, so the two rings together ARE the lesson: what you asked
for, what your timing gave you, and the difference between them.
**...and the flick fires the moment it IS a flick** (2026-08-20, Oliver: *"trying to move the
approaching ball with a flick to the left is almost impossible ... standing closer to the grundlinie
and more right from the center eases this restriction"*). The aim math was symmetric to the
centimetre — the defect was LATENCY. The swing waited for the RELEASE, so the whole gesture ran as
dead time on top of the 60 ms windup, and ~120 ms of lateness drops contact quality 1.0 -> 0.08,
where `flickReach` halves the width and `aimError` drowns what is left. A LEFT swipe is the slowest
direction a right thumb has (~250-350 ms vs ~150 for right), the service line leaves only ~700-870 ms
of budget against even the slowest rung (~1.1-1.3 s at the baseline — measured), and any swipe over
`FLICK_MS` (300 ms) was silently DEAD: no swing, no feedback, ball passes. Left + service line was
exactly the slowest gesture meeting the shortest budget. Two changes, rally modes only
(`rallyFeel`), input.js + one line in `trySwing`: **the swing fires when the drag crosses
`flickMin`** (in `G.phase === 'rally'`, once per gesture) rather than at release, and **the aim
stays LIVE** — `trySwing` reads `G.swipe` at contact, so the ball takes the vector under the thumb
NOW; the released `flickAim` only carries it when the ball arrives after the finger lifted. The
FLICK_MS deadline is gone with it where the crossing can fire — it survives only where it cannot
(gore's free-aim flick, the serve-phase strike), where a late release must stay a rescue drag. The
serve gesture, the duel and gore are untouched; keys unchanged. Verified with real touch events:
held-swipe contact aims exactly `swayOf`, a 360 ms swipe (previously dead) swings and aims, and a
release-before-arrival still aims through `takeFlickAim`.
**...and an aimed shot keeps its DIRECTION** (2026-08-20, same report, second round — the crossing
fire alone "did not feel much different", and his sharpened observation was that the flick only
seemed to work when the opponent already stood where he aimed). Measured with the opponent
teleported at contact: the aim never tracked the body — no magnet exists — but at rushed quality
the SYMMETRIC scatter (±aimError·(1-q)) outweighed the quality-shrunk aim, so a mistimed left flick
landed anywhere in a ~4 m blob around "slightly left": usually centre, sometimes right, and the eye
anchors that on wherever the opponent stands. The opponent's position was a proxy for preparation
time (a body parked wide = its return flew a long slow diagonal = time = quality). The dial:
**`TUNE.aimTrust` — an OUTWARD scatter roll is untouched** (a committed mistimed swipe still sails
at the exact tuned rate; `flickReach` stays the only out dial) **but an inward roll keeps only
`aimTrust` (0.3) of itself**, so bad timing costs width, never the direction that was asked for.
Player lane shots only: the opponent's symmetric scatter is its width mechanic, and gore's
free-aim never used lanes. Verified in real rallies at worst-case q≈0.08: 14/14 rushed left flicks
landed left (−0.8..−3.5), 9/9 right flicks right — previously the same condition was a coin toss.
The suite pins the direction guarantee and the knob relationship (`aimTrust·aimError` must stay
under a committed swipe's worst-case aim). Whether placement should also BEAT an unwounded body
more often (`aiRead` on wide balls — "dial 2") is open, awaiting the phone verdict on this one.
**...and the flick aims RELATIVE TO THE PLAYER** (2026-08-20, third round, and this was the core
all along: *"when the opponent and the player are both standing in the left service box it is
virtually impossible to flick the ball into the left out. no matter how hard/good I flick"*). The
lane was ABSOLUTE — a full flick asked for `flickWidth` off the court's CENTRE — so the gesture
saturated near a sideline: from x −2 a perfect left flick moved the ball under 2 m and could never
pass the line (by design, 3.95 vs 4.115 — and the better the timing, the firmer the cap, because
scatter was the only thing that could cross). The felt "left is impossible / right works" was
never left-vs-right: it was toward-the-near-sideline vs across-the-court. **`TUNE.aimAnchor`**:
the aim's anchor slides from the court centre to the player's own x as the swipe commits
(`anchor = ch.x · |sway| · aimAnchor`), so a flat tap is exactly what it always was and a full
flick is a full ±flickWidth sweep FROM WHERE YOU STAND — including past the line. **Out-by-aim
exists now and is a choice** (his explicit direction: *"not about the possibility of the ball
going out but rather the aiming itself"*); the placement ring already turns red for it. 0 restores
the old absolute lane, blendable, panel-tunable for live A/B. Player rally lanes only — the
opponent's lanes and the pOut invariants are stated from centre and unchanged there. Verified in
his exact scene (both bodies in their left boxes): full left flick lands −5 (the alley), half
swipe −3.07 (hunting the line, in), full right +1.93 (a full sweep measured from him).
**...VETOED the same day and replaced by the wrong-side guard** (2026-08-20: *"no that's way too
difficult now - I am dying constantly of out. revert it."*). The relative lane is gone
(`aimAnchor` deleted; git has it if ever wanted). What survives is the answer to his follow-up —
*"the ball sometimes actually flies a little to the right, although I flicked left. how can this
be?"* — which is the absolute lane's blind spot stated the other way round: a mistimed left
flick's target sits only slightly left of CENTRE, and that is RIGHT of a player standing in the
left box, so the ball visibly flew rightward out of a left flick. **The aimed target is now
clamped to the player's own x on the flicked side** (bounded at ±`flickWidth`, so a body parked
in the alley clamps to the corner and can never conjure an out): from the left box a left flick
goes at least down your own line, from centre it can no longer cross centre, a perfect corner
flick and the crosscourt sweep are untouched, and the outward scatter tail — the whole tuned
out-risk — is unchanged. Verified in his scene: ten rushed weak left flicks landed −2.0..−2.7
(previously targets −0.7..−1.3 — a rightward flight); the clean full flick still lands −3.92.
**The umpire and the boy.** `aside()` refused during any interlude and the match runs on a permanent
one, so he was silently muted for the entire mode — the one mode that is literally a tennis match he
is sitting over (`G.interlude && !G.match` now, the same shape as `sendBoy`). Six dry lines: two on
taking a limb, two on your blood, one when it is nearly gone, one on felling the thing, all behind
the shared `asideEvery` cooldown. **The mop boy comes out between games**, to the FAR court
(`sendBoy(0, 4.5, true)` from `matchGameWon`) — that is where one body has been bleeding through
four wound stages, and it keeps him out of the near court where you are about to serve. Between
games only; never mid-point.
**Opponent serve games** were almost entirely switched off rather than missing: `setupPoint`'s
tennis branch already placed a non-player server and armed `G.aiServeT`, and `update()` already
tossed and struck for O — both gated on `G.mode === 'tennis'`. What was genuinely new: **service
alternates at each game boundary** (the match calls `advancePoint` directly and the flip lives in
tennis's `pointTo`, which the match bypasses — the same shape as the `G.serveNum` trap below), the
zombie takes O's mark when it serves so the picture and the ballistics agree, and the serve POSES are
named onto it (`bounce` → `toss` → `serve`). *Known simplification:* the interlude's line-call branch
has no serve box and no second serve, so a missed opponent serve is simply the player's point. Add
faults when it matters.
**CLASSIC TENNIS is a real mode, not the opener with the turn switched off.** Oliver: "if the core
tennis is good everything else is good." Six games with a two-game lead is a set, `TUNE.classicSets`
is the match, no tiebreak (marked `ponytail:` — add it when a real match stalls at 6-6). The CPU
stops rubber-banding there: `aiFlubLoss` exists to get a new player past the opener and would have
him in pieces by the second set. It has its OWN ENDING with no corpse in it (`G.done = {won, t}`);
all three endings share one render block, because three copies would drift the first time one moved.
**THE RECORD SHAPE — read this before touching stats.js.** One character `m` on the run record
(`g`ore / `z`ombie tennis / `c`lassic; absent means gore, so no migration). The record's two headline
slots carry each MODE's own two numbers:
| mode | `g` | `s` | `dead` |
|---|---|---|---|
| gore | games survived | sets banked | died |
| zombie tennis | **opponents felled** | games played | died |
| classic | games played | sets won | **match LOST** |
`g` is what everything ranks on, so each mode's real measure goes in that slot and there is no second
best stored anywhere to drift. `read(period, runs, mode)` filters; the stats screen is one function
with three sets of words. The world board stays the gore mode's — it ranks kills in a run, and a
five-body match would post a "5 kills" row next to a 300-kill massacre. A tennis board would rank
FELLED with points won as the tiebreak; that is a server change.
**The sprites.** The match opponent has its OWN art (`z_r*` and the nine families beside it), drawn
off the LIVING opponent's poses: ready stance, shuffle pair, forehand, at each wound stage, both
tours, plus the serve and the fall. The frame is picked off `racketAI`'s own `pose`, so the picture
cannot drift from the behaviour. `z_c*` (the turned CPU) is untouched and keeps its shamble — in the
gore mode shambling is the right thing for it to do.
### Traps this mode has already sprung — do not re-learn these
- **`autoAssist` is 0 on `pointer: fine`.** The desktop pane has no auto-run. The match floors it
with `matchAssist`, and `assistK()` is shared by the mover AND `intercept` — computing
reachability with the raw knob made the planner answer "run home" forever.
- **The contact chain is three separate things**: LEGS (`matchAssist`) → DESTINATION (`assistK` in
`intercept`, plus the `anyHit` short-ball candidate that chases dying rollers) → REACH
(`matchReach`). Each was a separate defect found in a separate recorded session.
- **`ball.lastHit` must be stamped by `tossBall`.** A stale `'O'` on a toss from behind the baseline
read as the opponent's passing shot: a point and blood PER TAP. Fixed, but the shape recurs —
anything that revives the ball must own it.
- **`G.serveNum` is only reset by tennis's `pointTo`**, which the match bypasses. `matchReady()`
resets it; a new mode with its own point boundary must too.
- **A mode gate written as `G.mode === 'tennis'` excludes the match** (it is `'splatter'` under a
permanent interlude) and INCLUDES gore's prologue. Reach for `G.match` / `rallyFeel()` instead.
- **`stats.banked` is only cleared by a KILL**, and classic tennis has none — so the second and every
later classic match of a session was silently swallowed by the double-bank guard. `beginRun()` is
the reset now. Any future mode with no kills in it needs that path.
### Do not
- Re-add regicide to the match (killing the body with a smash) — removed deliberately; it made every
hard serve a free unaimed kill and smeared the score-is-the-body relationship.
- Touch the gore mode's contact/recovery rules while tuning the match. `matchReach` and the
centre-recovery are match-only ON PURPOSE; gore splatter's tight contact and depth-only recovery
protect the sidestep.
- Put a body-shot thrower in the match. `shots[]` and the PARRY are built and unused here, and it
looked like the cheapest depth in the mode — **Oliver scratched it, 2026-08-17.** Do not revive it
without him asking.
- Turn `drillOneShot` on as a default — it skips every `ZWOUND` stage, so the roster's best art never
renders.
- Revive the TRIAGE DRILL without a new idea. It still works exactly as designed and it failed the
only test that matters: *"i think the normal player does not grasp this."* Four designs died
learning that **an invented rule needs teaching; the rules a scene already implies do not.** That
is why the match is tennis.
**Expansion ideas that survived discussion** (also in LATER): the tournament frame (QUALIFYING →
ROUND OF 16 → THE FINAL vs THE CHAMPION), the Hawk-Eye challenge as an earned continue, sudden-death
deuce past a given opponent, doubles, and multiplayer.
## Session log: a lane is free when its body is gone, not when it is far away (2026-08-16)
A seed-sensitive red suite (`SEED=1234 node test.js` failed on the drill's "spaced *after the walk
in*" assertion) turned out to be the assertion telling the truth: **two drill bodies genuinely walked
through each other.** The spawner measured its best-of-9 lane spacing only against bodies with
`z < drillNearZ` (9 m) — sound for a court where bodies wander, wrong for this one, where a lane is
HELD for life and speed is stamped at spawn. A body 12 m down still owns its lane; handing that lane
to a faster arrival does not create a spacing problem at spawn, it books an overtake for ten seconds
later, mid-court, as two sprites occupying the same metre. The fix is the filter: spacing is measured
against every live drill body, `drillNearZ` is deleted (it had no other reader). No retry loop, so
the stream still cannot stall.
Measured, 60 seeds × 150 s of drill each: before, **7 seeds produced overlaps, narrowest clearance
0.00 m**; after, **zero overlap frames, narrowest 0.46 m** (the test asks for > 0.3). Peak on court
is still the cap of 7, so the fan is wider, not thinner. Suite green on 20260811 / 1234 / 777 / 99 /
424242 / 8, and on 299 of seeds 1..300 — the one straggler is unrelated and still open (see below).
**The one straggler the same fuzz found, and the arithmetic to copy for any rate gate here.**
`SEED=80` failed *"opponent one returns a slid serve: 5/10"*. The receiver is innocent: measured over
200 trials a seed across eight seeds, the real return rate is **0.85..0.92**. The gate was simply too
small a sample — asking 6 of 10 at p ≈ 0.88 fails with probability ~0.004, one seed in 230, and 300
seeds duly produced one. **n is now 40 at the same 60% bar**, which puts the threshold five standard
deviations below the true rate while still failing instantly if the receiver regresses toward the
0-in-24 the test was born from. It costs nothing — a returned serve breaks the frame loop early, so
the whole suite still runs in about a tenth of a second. Green on seeds 1..400.
The general lesson: **a pass/fail gate on a random rate needs its sample size derived from the rate,
not guessed.** If the true rate is p and the bar is b, n has to put b several standard deviations
(`sqrt(p(1-p)/n)`) below p, or the gate is a slow coin flip that trains you to re-run the suite.
## Session log: THE NETPLAY LOBBY — a link to send, a code to type, a name to be (2026-08-15)
Oliver: *"multiplayer should be easier to setup — a share with link next to the code; also entering
the code is done in some plain js confirm dialog thingy, this could be embedded more nicely;
somewhere when hosting or also when joining the player should name themselves."* All three are one
screen (`#vs`, `index.html` + THE LOBBY in `main.js`); the protocol underneath is untouched.
- **THE LOBBY IS A MENU SCREEN, and that is the whole structural change.** Hosting used to call
`start('classic')` first and show the room code in a CANVAS modal over an empty court — which
cannot carry a share button, cannot be typed into, and turned a mistyped code into a CONNECTION
LOST card over a match that never began. The socket is opened from the menu now and the game
stays parked behind the overlay until the room actually fills. **`netHooks.paired` is what takes
the court**, called at the top of `netStart` — one line before everything that writes to it, so
the kit/tour selection `start()` performs is already done when the `look` message reads it.
Two more hooks join `intoSplatter`: `room` (the code landed) and `lost` (returns true when the
lobby said so itself, which is what suppresses the card). Headless leaves them null.
- **SEND THE CODE — and no link (settled same day).** `navigator.share` is the phone's own sheet —
the friend is in WhatsApp, not in a room reading letters aloud — with `clipboard.writeText` as
the desktop fallback. It shipped as SHARE THE LINK for an hour and the link came straight back
out: **a URL only opens the INSTALLED app on Android** (the manifest's scope covers the site, so
its WebAPK claims these URLs), while **an iOS home-screen app can claim nothing**, so on an
iPhone the invitation opened a second copy of the game in Safari — its own storage, none of the
name they picked in the app. Four letters typed into the app they already have is the honest
version until this is a real app with universal links; the `#net-ABCD` door still exists and is
exactly what a deep link will carry the day there is one, nothing merely sends it now. That door
prefills the lobby rather than auto-joining, so the joiner still sees the name they will wear.
- **ONE NAME PER PLAYER.** The field is prefilled from `stats.savedName()` and written back through
the new `stats.saveName` (which `submit` now shares), so the world board's name and the netplay
name cannot become two things. It crosses on the EXISTING `look` message as `n` — no new message
type, and an older build simply sends none. **The scoreboard names the person** (`render`, the
names block) in both netplay modes: FRIEND until the look lands, never CPU. A stranger's string
lands in an 8-character slot, so it is normalised at the RECEIVING end through the same
`tidyName` the board and `server.py`'s `clean()` agree on.
- **Failure is a typo now, not a card.** A short code never opens a socket; `gone` puts
NO SUCH ROOM — CHECK THE CODE over the picker with the field still there. CANCEL and the header
chevron both `endNetVs`, so a room you walked out of gives its socket back.
- Verified through the real relay in two panes: host → share link → join → both take the court,
host serves, the board reads YOU / GUEST, and the death-match room rebuilds both courts the same
way (blood pips, the friend as the living opponent, no roster body). 96 tests.
- Smaller: the JOIN button is the crimson plate stretched to the field's own height (`#vs .joinrow
.btn` — a bare `.joinrow .btn` loses to `#vs .btn`'s margin, and a margin on a stretched flex
item is exactly the 8px it was coming up short by); `.tin` is now the one typed-field look, worn
by the name prompt and both lobby fields.
## Session log: gore opens in the gore, the theme comes back, three boards (2026-08-15, same day)
Oliver's finetuning pass on the batch below.
### THE GORE MODE STARTS IN THE GORE
*"the splatter gore should not have the normal tennis intro anymore, we should just start right in
splatter mode."* The prologue was a game of real tennis you had to WIN before the mode you picked
would begin — a reveal the first time, a toll booth every time after.
`gameplay.startGore()` is the direct door: it lands on the same state the transition's last beat
lands on (extracted as `enterSplatter`, so the two doors can never drift), sets `everTransitioned`
so the cinematic cannot fire behind it, and spawns the turned opponent as your first body — he is
the man you came to play and the mode's premise is that he is already one of them. **The transition
is NOT deleted**: it is still what `forceZombies` / `T.forceZombies()` plays, and it is still the
best thing in the game to look at. It just no longer stands between the menu and the mode. The mode
poster stopped promising a match that turns, because there is no longer one.
`main.intoSplatter` — the back room's "get me into splatter" hack — now calls `startGore()` too,
which deletes its 900 hidden `update()` calls. That loop fast-forwarded the whole eight-second
cinematic inside a single frame, firing every sound it makes at once.
### THE MENU THEME COMES BACK, AND THE COURT STAYS ON THE COURT
*"after some games — or returning to the main menu via the close button — it does not play the
background music anymore but you sometimes still hear the court background noise."*
Two halves of one bug, and both are `loopBed`'s early return. **It no-ops once a file is playing**,
so `menuMusic()` could only ever START the theme and never bring it back from the duck `start()`
put it under — the menu was silent for the rest of the session from the first run onward, and
quitting was the reliable way in because the death summary is the only path that ever called
`menuDuck(false)`. Meanwhile nothing ducked the court's own two beds at all, so the ambience
followed you onto the silent menu.
`menuMusic()` is now the one call that means "we are in the menus": it starts the theme, un-ducks
it, and ducks both court beds. `ambient()` and `zombieMusic()` un-duck themselves for the same
reason — the SECOND run would otherwise have been played in the silence the menu left them in.
**A BED MUST RESUME THE CONTEXT WHEN IT FINISHES LOADING, NOT ONLY WHEN IT IS ASKED FOR.** The
second half of the report — *"sometimes, when initially entering the game and tapping somewhere,
the background music does not start; I suspect the sound is not yet loaded"* — and he had it
exactly right. The tap unlocks the context and starts a 768 KB fetch; the `src.start()` that
follows happens seconds later inside a promise, with no gesture anywhere near it. iOS suspends an
idle context in that gap, and **a source started into a suspended context is silent forever** —
nothing on the splash calls `audio()` again, so the theme simply never arrived. `loopBed` now calls
`audio()` a second time in its `.then`, which is allowed because the first tap already unlocked the
context. That is why it was intermittent: it only bites when the decode outlasts the browser's
patience, i.e. a phone on a cold cache. A `visibilitychange` resume covers the same trap from the
other end (a phone that locks ON THE MENU came back silent; during play every pock resumes it for
free, but a title screen makes no sound of its own).
**...and each of those un-ducks ITS OWN BED ONLY.** The first cut had `ambient()` un-duck both
court beds together, which put the splatter music back on in classic tennis for the rest of the
session — Oliver caught it on the retest: *"after playing the splatter gore, the zombie background
sound also keeps playing in the normal tennis game."* LEAVING the court is one event and ducks both
beds; ARRIVING is not. The ambience belongs to every mode, the zombie bed belongs to the gore, and
only `enterSplatter` may bring that one up.
**The rule, for anything added here later: a bed's start function must also un-duck it, because
starting it is a no-op the second time and ducking it is not.** `audio.bedTest` exposes the duck
set to the suite (same idea as `netTest`), which is what makes this testable at all — the beds
never load headless, but the INTENT is the thing that was wrong.
### THREE WORLD BOARDS
*"the world rank is still about the splatter gore which is not the only thing — we need something
similar for the other modes."*
`stats.BOARDS` is the only place that knows what each board ranks, and the server does not know at
all: it sorts `k` then `g` within a mode and nothing crosses.
| board | ranks | second column |
|---|---|---|
| SPLATTER | kills in a run | games survived |
| TENNIS (arcade + cup) | opponents felled | games |
| CLASSIC | games taken off the CPU | games played |
Classic needed a number worth ranking — every win is exactly two sets, so a board of 2s is a wall
of ties. `G.classicWon` counts the games YOU took and rides in the record's `s` slot, which held
sets until now and which **nothing has ever displayed** (the sets line on the death screen is
gore's; `read()` has never looked at `s`). The name prompt is per-mode too, gated on the board's
own number rather than on kills — the old `run.kills > 0` test silently barred classic (no kills)
from ever reaching it.
Server: rows carry `m`, `GET /api/scores?m=g|z|c` filters, POST keeps one row per name **per
board** and trims `KEEP` **per board** so a busy mode cannot evict a quiet one. Name ownership
stays GLOBAL — one person owns their name on all three, or a stranger could take it on classic
while you hold it on gore. Rows written before the split carry no `m` and are gore rows, which is
the same convention the client has used since the stats screen split, so there is no migration.
### THE GORE MODE GETS HARD AGAIN
*"in splatter gore it now gets too easy to just play through. Maybe we should reduce the barrels
again — pretty early in the game every won game gives a barrel. The splatter gore should be
unbelievably hard towards the end."*
He is right and the arithmetic said so. `barrelEvery 3 / barrelRamp 8` drove the gap to **one** at
game 16 and left it there, so the entire back half of a run paid out a weapon for every game won —
while every difficulty ramp in the file reached its ceiling around game 25 and stopped. A run got
richer after game 25 and never more dangerous.
- **Barrels: 5 / 14, and a new `barrelFloor` of 3.** The ramp survives (the late game does need
more inventory than the early one), it is just slower and it STOPS. `gameplay.barrelGap()` is the
curve, exported and pinned by a test, because this is exactly the kind of one-liner that quietly
becomes an income again.
- **`zSpeedMax` 2.9 → 3.6** — shamblers keep gaining speed to ~game 28 instead of 22.
- **`nastyMax` 0.75 → 1.0** — by game 34 there are no ordinary shamblers left in the mix at all.
- **Ball machines scale**: `machCap()` is 2 + one per `machMaxPer` games, capped at `machMaxCap`.
The one kind of late pressure that does not crowd the ground — they hold position and shoot, so
the court stays readable while the number of things that can reach you goes up.
The body COUNT ceiling (`zMaxCap`) is untouched and stays untouched: it is a readability limit on a
270 px screen, not a difficulty dial. What escalates now is what the bodies ARE and how fast they
arrive. **Feel-pending: these are dials, and the end of a long run is the one part of this game
that cannot be judged from the numbers.**
## Session log: the roster doubles, the draw is made on screen, and netplay stops locking (2026-08-15)
Oliver's seven-item batch. All of it is on STAGING and pending his phone verdict; items 3-5 in
particular need two phones on cellular, which is the only place the bug they fix was ever seen.
### 1. SIX NEW OPPONENT FAMILIES, and the ladder goes 12 -> 18 rungs
*"expand the roster for the zombie tennis, use the proven way of generating the roster with the
refcheck... head coach, stringer, strawberry seller, chair umpire, spectator, roller man."*
Rolled through `_famroll.py` -> `_zsheet.py` exactly as the WHICH-TOOL table says, keys `hc sg sb
cu sp rm`, each anchored on the body it already has in the gore mode (`z_coach1`, `z_string1`,
`z_berry1`, `z_chair1`, `z_s1`, `z_k1`) — the rung and the thing that hunts you stay one person.
186 new sprites, 6 new head icons, `_heads.py` SRC/BOX rows for all of them. Placed by HIERARCHY
rather than stacked on the end (spectator 3, roller man 5, seller 9, stringer 10, chair umpire 12,
head coach 14), so the ladder still reads as the club's staff from the bottom up, and **the
champion took the step up Oliver asked for**: 1.45 speed / 1.42 pace / 1.36 width / 0.38 flub,
against 1.35 / 1.30 / 1.30 / 0.50. `CUP_ROUNDS` bands widened to match, `LADDER` is exported so the
ramp test and the draw screen read the table instead of a pinned 12.
**TWO PIPELINE LESSONS, both paid for in rolls:**
- **THE RACKET IS THE POSE, NOT A PROP — say it out loud.** All six of these characters carry
something in their anchor frame (a ball basket, a coil of string, a tray, an umpire's chair, a
folding chair, a court roller), so each description had to say the thing is gone — and the model
read "he carries nothing in either hand" as covering the RACKET too. Two families came back
playing tennis bare-handed and the strawberry seller returned every shot with his tray. There is
now a permanent clause in `_famroll.prompt()` saying the racket belongs to image 1's pose and is
never removed or replaced. Describe a prop's REMOVAL by naming the prop ("the tray image 2 shows
is NOT part of him"), never by talking about what his hands hold.
- **A prop at head height defeats `_heads.py`'s shoulder-widening rule every time.** All six needed
a hand `BOX`; the automatic crops came back 88x18, 36x17 and 35x16 — a letterbox of scalp. If a
new family's head icon is a thin strip, that is the diagnosis, and a `BOX` row is the fix.
- The club pro finally has a head icon too (`z_rhead.png`, cut from `z_c1` — the turned opponent
IS him), so `SPR` preloads all of ZFAMS and the draw screen's portrait column is consistent.
- **`ENEMIES['L' + i]` was reading BOSS_ART for the NAME and OPPONENTS for the line** — the same
answer only while the two lists were the same list, and they were decoupled on 2026-08-13. The
body-count screen has been printing THE PHYSIO over the mascot's line ever since. It reads one
list now, which is also what stops rungs 11-18 having no row at all.
### 2. THE DRAW — the tournament's bracket, settled in front of you
*"when starting the tournament there should be a view of the tournament hierarchy but only the
finale is settled... random browsing through the roster in the individual spots which begins fast,
gets slower and then settles on a random character, so that every tournament is new."*
A new menu stage (`#bracket`, `main.buildBracket`), not a court-owning mode: SERVE UP lands here
when the picked mode is `cup`, four rows roll their own band of the ladder at a gap that grows with
p², they land in order at 1.5 / 2.05 / 2.6 s, and ENTER THE DRAW is `disabled` until the last one
stops. The final wears its settled look from the first frame — it was never in doubt.
**THE SCREEN IS THE DRAW, NOT A TEASER.** `startMatch(cup, draw)` takes the settled rungs and
`cupDraw` reads them off instead of rolling its own, so the face you watched land in ROUND ONE is
the body across the net. A screen that dealt a different tournament from the one it just showed you
would teach the player that the whole ceremony is a lie. `cupDraw` still validates each rung
against its round's band and rolls honestly if it does not fit, so a stale or hand-passed draw
cannot deal THE CHAMPION in round one. `#cup` (the back door) passes no draw and behaves as before.
### 3-5. NETPLAY: the deathmatch lock, the court, and the ball that froze
**The lock — "sometimes both players stand on the same side and nobody can serve" (cellular).**
ROOT CAUSE FOUND AND IT IS ONE LINE: the mid-flight early-decide in `update()` was missing the
`!G.net` gate its bounce-1 sibling in `onBounce` has carried since the netplay session. It exists to
predict THE CPU'S LEGS; against a human on another phone it made the STRIKER a judge of its own
outbound flight (`resolveInterlude` sends the verdict), so on a slow link both phones scored the
same rally. The two scoreboards then sat a point apart forever — and that is not cosmetic, because
`setupPoint` reads `score.pts` for the deuce/ad side and `score.server` for who tosses. One point of
drift puts both players on the same half of their own court, each waiting for the other to serve,
with no way out. **The one rule is that the receiving phone judges; this was the last place still
breaking it.** The 2026-08-14 log claims the early-decide was gated — only the bounce-1 half was.
Belt and braces, because that failure mode is a soft-lock rather than a wrong number: **the HOST's
10 Hz position stream now carries its scoreboard while it sits in `pretoss`, and the guest adopts
it mirrored** (`netSync`). Gated to `pretoss` ONLY — nothing is mid-beat there and any verdict in
flight has long landed — so a live rally is never reconciled underneath itself. One authority, no
new message type. `score.games` is deliberately NOT synced (ponytail): it costs a wrong number on a
board rather than a court nobody can serve on.
**The court rides the room.** `?court=<id>` at creation, stored beside `death` on the host's room in
server.py, carried in the `start` message, applied by `netStart` -> `selectCourt`. Safe mid-run: the
bounce pair is copied into TUNE and the backdrop cache keys on `court.id`. Two phones playing one
rally on two surfaces disagreed about the bounce as well as the picture, and the guest never chose.
**A called ball plays out.** *"as soon as the ball goes out the gameloop stops — it should just play
until it came to a natural halt."* Every scoring path used to switch the ball off where it stood,
which on the STRIKER's phone is a whole court and a latency away from the call. `ball.scored` is the
new flag: the flight is decided, physics keeps running, and the ball dies where a real one would —
by rolling to a stop under the existing `|vy| < 0.8` dead-ball rule. `endFlight()` in gameplay.js is
the one place that decides which it is (netplay rolls, everything else stops dead, so no
single-player feel changed), and `onBounce` returns on `ball.scored` at the top, which covers every
call site in one line. The three consequences further down the physics block (the dead-roll branch,
the baseline crossing, the retire) carry the same guard. `setupPoint` clears it.
### 6. THE NIGHT COURT
A `COURTS` row (`gloom`, THE NIGHT COURT) plus one generated menu tile. It needed no new palette
machinery — its TENNIS palette is simply darker than every other court's SPLATTER one, and
`paintBackdrop` already paints every line in `UI.cream`, so the lines read as chalk under
floodlights with no new knob. It borrows the hard court's stands and boards rather than growing two
more sheets. **The one thing it did need is `dim`**: a fraction the STAND is taken down by before
the rot even starts (`render.standDim`, 0.62 here and absent everywhere else). Without it a
floodlit blue crowd sat above a near-black court and the picture stopped being a night. `standDim`
is ONE function because the number is applied twice — to the cached sheet and to the live clap
columns drawn over it — and two copies of it is a stand whose clapping columns pop brighter than
the rest of it, the same class of bug as the two crowd frames drifting apart.
### 7. CAMERA FLASHES IN THE STAND
`render.tickFlashes`, an OVERLAY like the clap ripple and for the same reason: the backdrop is one
cached blit and anything animated inside it repaints the whole scene every frame. Clocked off
`G.idleT` (the function has no dt, and idleT is the clock that never stops). A flash is a hot core
plus a cold bloom, `TUNE.flashLife` 0.16 s — deliberately short, because a flash you can look AT is
a lamp and one you only catch is a photograph being taken. The first cut drew a 6 px disc and read
as a floodlight; it is 1-2 px now. `TUNE.flashEvery` (5 s, jittered 0.35x..1.95x) is the one knob,
halved again while `crowd.cheer` runs — which is the "slightly increase during applause".
## Session log: MULTIPLAYER — the live 1v1 socket (2026-08-14). PHONE VERDICT PENDING
Oliver: *"alright multiplayer"* — and, offered the recorded ghost-match step one, he picked the
LIVE SOCKET instead, plus *"a new button with generated art in the main menu — the possibility to
start or join a game"*. Built to the architecture LATER item 7 recorded; verified end to end in two
browser panes through the real relay (pair → serve → mirrored flight → verdict → both scoreboards
agree on the same point from opposite perspectives). 73 tests.
- **It is CLASSIC TENNIS between two phones** — `G.classic` with the O opponent driven by the other
phone instead of the AI. The mode poster is **VS A FRIEND** (`mode_vs.png`, generated in the
zombie poster's night-court style); its second step is two chips, HOST — GET A ROOM CODE / JOIN
WITH A CODE, and either chip IS the start — a room needs a socket, not a setup screen. `#net`
hosts and `#net-ABCD` joins from a URL. The room code lands in a modal over the frozen court
(an empty court waiting for a stranger is the calmest court there is).
- **The relay is ~110 lines of stdlib in server.py** (RFC6455 handshake + framing, `/ws`). It
holds NO game state: mints a 4-letter code (no I/L/O/U/0/1 — codes are read aloud), pairs exactly
two sockets, forwards text frames verbatim. The host serves first on both scoreboards. 75 s
socket timeout against vanished radios; the client heartbeats every 20 s.
- **The sync unit is THE SHOT** (`src/net.js`): each phone simulates its own court with itself as
P; a strike sends ~9 numbers and `mirror()` (x → -x, z → COURT.L - z) rotates them half a turn.
The 10 Hz `pos` stream drives the O sprite and carries no authority.
- **THE ONE RULE: the phone the ball is flying toward is the only judge of that flight** — line
call, service box, double bounce, its own swing. The striker's outbound ball is picture
(gameplay's tennis bounce block returns early on `G.net && lastHit === 'P'`) and the verdict
arrives as a message: `shot` (returned), `fault` (re-serve), `pt` (point, winner relative to
sender). This is what stops two sims a latency apart double-scoring a rally — the striker's copy
would call a double bounce before the defender's swing had happened. An `applying` latch stops a
received verdict echoing back out. Six tests pin the rule from both seats.
- **startNetVs/endNetVs live in beats.js** under the ownership contract, and `G.net` joined every
other start*'s refusal list. Serving is barred while unpaired or dead (`keyPressed`), O's auto
serve and rally AI are gated off, `restartRun` closes the socket.
- **Honest limits, all deliberate (ponytail)**: NO reconnect-and-resume — a dropped radio is a
CONNECTION LOST card and a reload (the recorded plan calls reconnection "the bulk of the real
work"; a prototype must not start there). ~~prompt() is the join UI. The scoreboard still says
CPU.~~ *(both gone — see THE NETPLAY LOBBY above.)* A return snaps the ball to the mirrored strike (~latency of drift eaten in one hop; the
flight swallows it). Runs bank as classic runs. The wound-decay hook (losing IS zombification)
is the recorded next step once the feel survives a two-phone playtest.
- **Test on staging with two phones** — the relay deploys with the ordinary push, nothing to
provision. Coolify's proxy passes WebSocket upgrades as-is.
**SAME DAY: THE HOST PICKS THE MODE, and DEATH MATCH is the second pick.** Oliver: *"the one
creating the room could decide whether it is classic tennis or death match.. in deathmatch the
wounded opponent would show increased dynamic bleeding before exploding."* The VS second step is
three chips now (HOST — CLASSIC TENNIS / HOST — DEATH MATCH / JOIN WITH A CODE); the host's pick
rides the room (`?death=1` at creation, carried in the relay's start message), so the joiner never
answers a question. On pairing BOTH phones rebuild the court into the match machinery — the same
verdicts drive `resolveInterlude`/`matchPoint` instead of `pointTo`, so blood, the mop beat and
the scoring come along unmodified. **THE OPPONENT IS THE FRIEND, NOT A ROSTER BODY** (his retest:
*"the opponent reads as zombie — it should be the normal opponent"*, and no SHAMBLER teaser
card): nothing spawns, the living O carries the whole picture, and since the human sprite has no
wound skins, **the score reads on him as BLOOD** — `bleedO()` in zombies.js opens an anchor per
point taken off him this game and thickens the flow, love → game point, thin drops throughout
(the settled rule: more emissions, never heavier drops). The game-winning point BURSTS him where
he stands (`explode` — the human has no fall art, and procedural gore is the sanctioned fake),
the mop beat runs over the gap, and IT RISES AGAIN returns him with the ramp reset. Their won
points draw your blood; bleeding out sends `dead` — the other phone banks a NOT-dead match run
and holds the win screen. Wired details: `d.opp` frozen (no roster climb — same friend every
game), racketAI / the early-decide / the empty-court-regicide check all gated `!G.net`, the heal
beat is a CARD in netplay (the modal froze this world while the other phone's kept moving), and
`gushProne()` gives the SOLO deathmatch's kill fall the same bleed-out from prone-height anchors
(bleed() stays suppressed there — standing anchors are the "blood fountains midair" bug). #netd
hosts a death room from a URL. 79 tests; the blood-ramp/burst/return loop verified across panes.
## Session log: the far court, without spending the lens (2026-08-13)
Oliver: the far-side opponent is barely visible in portrait, and the lens (camZ/camF, already at its
measured ceiling — see fitViewport) and world height were the only knobs he knew. Two new ones.
VERDICT (same day): the scale curve is settled-in (*"really good — helped a ton"*), the lean was
vetoed (*"the camera movement is distracting"*) and its knobs sit at 0 — see the settled list:
- **`TUNE.farK` — bodies draw off a flattened scale curve, the court projects true.** The 16-bit
tennis trick: `s' = s·(sNear/s)^farK`, anchored at the player's deepest stance (he is untouched),
clamped so nothing nearer ever shrinks. At 0.25 the far-baseline opponent draws ~20% bigger
(31 → 38 logical px) and the court gives up nothing. Applied in render.js `farB(z)` to every body
AND to every anchor that hangs off a body height (flames, boss HP, muzzle glow, drill chevron,
umpire) — boosting only the sprite left the HP bar sitting on the boss's hat. Known residual: the
ball keeps the true projection, so at the far baseline it meets a boosted racket ~5px off. 0
restores pure projection.
- **`TUNE.farCamY`/`farCamZ` — a broadcast lean while the ball is on the far half** (tennis + match
only; the gore modes would pump). Eased like aimCam, and pivoted about the player's deepest stance
via `fOff` in render.js — without that pivot 0.35m of camY slides his feet 14px toward the rack,
and the apron math promised that room. Measured in-pane: far court rises ~14px and grows ~1.3%,
player feet drift ≤2.3px and return exactly. The backdrop cache repaints while the camera eases
(~0.35ms/frame desktop, settled key at both ends) — if the phone stutters in rallies, zero these
two before touching anything else.
## Session log: THE TOURNAMENT (2026-08-13, same day — Oliver picked it over the endless ladder)
The fourth mode tile: four rounds up the dead hierarchy (`config.CUP_ROUNDS` — each round DRAWS a
rung from its band, the final is always THE CHAMPION), riding the whole match machinery and
differing in exactly three places: who walks on (`cupDraw` puts the drawn rung in `d.opp`, so every
ladder dial downstream scales untouched), how deep the body's pool is (`matchOpponent` multiplies
by the round's `need` and `matchPoint` divides the same way, so wound stages SPREAD across the
round — the body enters game two missing what game one cost it), and where a won game goes
(`cupGameWon`: mid-round the same body plays on; round won is the kill + mop beat + next draw;
past the final, `cupWon` banks a NOT-dead match run and holds CHAMPION OF THE DEAD CLUB on the
classic win's summary shape). Blood carries; the trainer's heal-per-game is the distance. Round
one is a single game so a run opens fast; deathmatch stays beside it as the arcade mode.
Also this session: the fall ANTICIPATES the pass (a winner past the net with a closed-form
unreachable landing resolves mid-flight — plus the soft-lock the first version shipped: the
decided-latch must die with its rally, see the beats/gameplay comments); the fall table lost its
rise row and tightened its drop; the ghost trail is PACE-qualified so both tennis modes streak
(`trailSpeed`); and opponent sprites draw brighter via a load-time baked copy
(`render.brighten`, `TUNE.oppBright`) — no PNG touched.
## Session log: the serve gets stakes (2026-08-13, same day)
Oliver: *"the golden mark is just very easy to hit and hitting below it does not necessarily make a
worse serve... the flicking does not work here — also I see no option to serve harder."* Three
mechanical causes found, all fixed; PHONE VERDICT PENDING:
1. **The perfect window was ~0.3 s of hang time.** The 0.12 m snap around the apex, at toss
gravity, held PERFECT for most of the ball's float. Now `TUNE.apexSnap` 0.05 / `apexFall` 0.55,
and the gold band on the apex guide reads the same knob, so the picture tightened with it.
2. **The pace ceiling was the TARGET DEPTH, not the timing.** Measured with a replica of
serveArc's loft loop: at the old fixed mid-box depth, every serve above q≈0.7 was cut to the
same ~36 m/s — quality could not show. Player serve depth now rides quality (flush aims 0.3 m
inside the service line, the only depth where flat pace survives). Ladder: flush 49 /
flush+hard 52 / mistimed 36 / bad 30 m/s. Opponent depth untouched.
3. **The strike flick was splatter-only.** In tennis and the duel the strike gesture now aims:
horizontal component sways the target toward T or sideline (`serveAimX`), upward component is
pace (`serveHard`, with `serveHardSpin` — the extra dive is what buys the clearance; without it
the loft loop cut the hard serve BELOW the plain one). Pace and sway are paid for in scatter
(`serveHardErr`) against the real fault machinery — tap stays the safe second serve. Keyboard:
arrows sway, up is the hard one. Verified in-pane with synthetic flicks: 49.4 / 52.5 / 51.0
swayed +1.1 m, all legal at flush timing.
## Session log: nothing arrives unannounced (2026-08-11, same day)
Oliver's retest: *"the horde is not announced anymore"* and *"bosses are just there now"*. Both were
true, and both were the same mistake — the announcement fired on the same frame as the thing it
announced, so it was over before it registered.
- **The horde gets its breather back.** `warnHorde()` (beats.js) raises THEY KEEP COMING and holds
an ordinary 'over' beat for `TUNE.hordeWarnT` (2.4 s); `startHorde()` runs when it expires. The
cheat (`forceZombies`) still starts one instantly — it is a cheat.
The breather runs on **its own clock** (`G.hordeWarn`, ticked in update above the 'over' block),
deliberately NOT on the 'over' beat it also sets: any beat that moves the phase underneath it
would strand the latch forever, which is the `mgT` lock this file already has a section about.
`startHorde()` **returns a bool** now, and if the court has been taken when the warning expires
the caller re-arms `pendingHorde` — the set gets its horde later rather than losing it silently.
- **The chapter boss walks on behind an OVERLAY**, not a band card: the boss's name in `UI.bad` over
`ENTERED THE COURT`. It is a modal (a boss stage is guaranteed calm — `startBossFight` clears the
court two lines earlier, so this is on the sanctioned list) with the new `m.hold` self-dismiss
clock, `TUNE.bossIntroT` = 3 s. A card in the routine-set slot and the routine-set size is why the
biggest fight in the game read as the boss simply being there.
- **The flamer does not trigger a horde** (his "vague feeling"). Arming any `kind: 'burst'` tin puts
you in the same CONTROL MODE a horde does — racket auto-fires, the finger steers you absolutely,
no serving — so a flamer or gun genuinely feels like a horde starting. Nothing in the weapon paths
touches `startHorde`; with the horde announced again the two are told apart by the announcement.
- Three tests added: the wave may not land on the frame of its own announcement, a mode
that steals the court during the warning re-arms the latch, and the boss overlay names the boss
and hands the court back without a tap.
**Same round, the burst weapons.** *"When firing a burst tin the weapon stops when it has destroyed
all visible enemies. It should just fire on."* It did, and the cause had nothing to do with enemies:
both the auto-fire and the steer were gated on `G.phase === 'rally'`, and every beat raised by
clearing the court parks the phase in `'over'` (a point, a won game, the mop-up). So the moment the
flamer killed the last body it went silent AND the player froze — worst possible timing, and it read
as the weapon jamming. **A burst is its own control mode for its whole `len`, exactly like a horde:
its own clock is the only thing that ends it.** The fire block in `updateZombies` has no phase guard
at all now, and the movement block runs on `G.phase === 'rally' || G.mgT > 0`. The settled "the
player only walks during rally" rule is untouched — nothing arms a burst except the player.
Deliberately NOT fixed by forcing the phase back to `'rally'` during a burst: that would stop `overT`
ticking, and a parked beat that never expires is how `pendingHorde` gets stranded (see the mgT lock).
**And the burst countdown is the TILE now, not a bar.** It was 1.5 px along the tile's bottom edge —
which is both the rounded frame and, on a phone, the very bottom of the screen. The armed slot's
WELL drains instead: a gold wash across the whole tile, emptying leftward, clipped to the tile's own
rounded path (`panel` leaves it as the current path) and drawn under the icon and labels. Measured
lit vs empty on the live tile: rgb(88,125,68) against rgb(31,84,49).
## Session log: THE COACHING DRILL — the cognitive mode, as a prototype (2026-08-11, same day)
Oliver and his team-mate decided the game needs **a different angle alongside the gore mode**, after
he reported the late game going repetitive: *"something that requires some immediate strategy other
than saving specific weapons for a boss I was killed by last time"*. Three modes planned — classic
tennis, **splatter tennis** (this), splatter tennis GORE (what exists). What went in today is the
middle one's feel-test, not the mode: **back room only** (`DRILL TEST`, or `#drill`), no menu entry,
nothing wired to tennis scoring.
**Why the late game is flat, for the record** (diagnosed before proposing anything, per the
meta-lesson). The optimal move never changes: kill the nearest thing, now. Every interruption is on
a fixed metronome (duel/3, machine/4, horde/6, boss/10, barrel/every game past 16), the director's
dials all bottom out by ~game 25, nothing is scarce once the rack is full — and **the zombie side's
score is unreachable by construction**: contact is a radius around the player and bodies home on his
x, so anything that could cross the baseline eats him first. The crossing branch in `updateZombies`
(and the `ump_unsportsmanlike` line with it) is very nearly dead code. Oliver caught that himself —
the first design direction proposed this session was built on it and had to be thrown away.
**The mode.** The umpire CALLS a colour sequence aloud while bibbed bodies jog on and take their
marks; then they come for you and you put them down in the called order. Colours never called are
DECOYS — bodies walking at you that you are required to ignore. Two faults = double fault = the
round is theirs. Settled in discussion: **audio-first, and shown as a pattern too.**
- **`voiceSeq` in audio.js** is what makes the call possible at all. `voice()` DROPS a line that
would start while the umpire is still talking (one man, one microphone) — correct for unrelated
triggers, fatal for a list. It decodes every clip FIRST, then queues them all at accumulated
offsets, so the anti-overlap rule is satisfied by construction rather than fought. Five new
recordings (`say -v Daniel -r 168` -> `afconvert`, the existing pipeline).
- **Four colours, not six**, and every bib carries PIPS as well as a hue: ~8% of men cannot separate
red from green, and a mode whose entire input is "which colour is that" owes them a second
channel. The same pips appear on the HUD strip, so the two readouts teach each other.
- **The bib is a DRAWN OVERLAY and it knowingly breaks the sprite rule** (`ponytail:` marked in
`drawBib`). Six bib sheets x every walk frame is a week of art to answer a question a trapezoid
answers today. If the mode survives its playtest, `drawBib` is the thing to DELETE and replace
with a real bib sprite per colour, drawn like a wound skin. Shoulders wider than waist — the first
cut flared the other way and every body was wearing a sandwich board.
- **TWO functions pick a target, and snapping only one of them was the bug.** `flickTarget` (the
slingshot) and `zombieTarget` (the plain tap) are separate paths; with only the first snapped,
fifteen tap-serves in a row scored zero and the round just ran out. Both snap now. Anything that
changes how a shot chooses a body has to touch both — same shape as the "three of five weapons
silently no-oped on the racket zombie" bug.
- **Contact costs the POINT, not the run** — a deliberate contract change, hooked in `killPlayer`
(the single death path for both damage channels, which is why one hook covers every way in).
Permadeath stays settled in the gore mode: a memory game where one lapse deletes a thirty-minute
run punishes the exact skill it is trying to teach.
- **The empty-court net** in `updateDrill`: a live round with nothing bibbed on court means something
outside the drill emptied it (seen once while wiring up, not reproducible afterwards). Unrepaired
that is a dead mode — no targets, no clock, nothing to tap. It redeals and logs `INVARIANT`, the
same contract `watchdog` uses. `startDrill` also DISARMS the ordinary schedule (`pendingHorde`,
`hordeWarn`, `bossDue`, `duelDue`): taking the court is not enough if someone else's arming latch
is still loaded behind you.
- **Eight new tests** (24 total) pin the ownership contract both ways, the call beat, the clean win,
the double fault, the un-winnable-round guard and the softer death.
**SECOND PASS, same day — Oliver played it: "the auto aim just mean no skills involved at all.
this should just go away.. there is no penalty at the moment.. make some variations of the position
on the court and the color".** All three landed, and the first two are the same finding: with the
shot locked to the called body the mode was unmissable, so **the fault could never fire** — there was
no penalty because there was no way to earn one.
- **Both snaps deleted.** You aim. What replaced them is one line: `aimTie` is **0 during a drill**.
That is not auto-aim, it is removing the *guess* — the ordinary magnet lets a nearer body inside
the same cone steal a shot aimed past it, which is right for a crowd you are mowing down and
exactly wrong where hitting the wrong body IS the failure. An unaimed tap now targets nothing:
`splatServeTarget` returns null and `hitServe` falls back to the service box, so the ball lands on
grass. Verified end to end in the browser: aiming at a decoy kills the decoy, raises FAULT, and
sets the rush; a second wrong body prints DOUBLE FAULT and the round is theirs.
- **The palette is SIX** (purple and white added, two more `say -v Daniel` recordings) **and the
court takes three to five of them** (`drillBodiesMin`/`drillBodies`). Four bibs out of a
four-colour palette meant every round dealt every colour and only the order changed — three
quarters of the round was known before the umpire opened his mouth. Now which colours turn up,
how many, and how many decoys are all live questions.
- **`PLACEMENTS` in drill.js — five shapes, weighted, mirrored left/right per round**: `fan`,
`cluster`, `column` (two or three SHARING A LANE at different depths — the shape that makes feet
matter, because the ball hits the first body it touches), `stagger`, `wing`. Lanes are shuffled
against the bib order, so the called order is unrelated to both the left-right order and the depth
order. Measured over 30 deals: all five shapes appear, counts 3/4/5 roughly even, all six colours
rotate, and **the first called target is screened by another body in about 10% of rounds** — often
enough to matter, rare enough not to be a tax.
- **The pips are DICE FACES now** (`PIP_FACES`, shared by the bib and the HUD chip so they cannot
drift). A row of five dots has to be counted; a die is recognised — and the redundant channel is
pointless if reading it costs more time than reading the hue. **Known limit: the die reads on the
HUD chip at any distance but degrades on the BODY as it recedes.** The bib colour carries; the
face does not. If that turns out to matter, the answer is a bigger bib, not more pips.
- Ten tests now (26 total), including a named regression for the auto-aim being gone and one that
deals 40 rounds and asserts colours, counts and shapes all move.
**THIRD PASS, same day — "the drill mode immediately goes into some horrible audio loop" (phone).**
Never reproduced on desktop, and that is the tell: **three of the four defects behind it can only
happen on a phone, and my whole verification had been stepping frames with rAF killed on a machine
whose AudioContext was already running.** All four are in `voiceSeq`, which is new code, and every
one of them is a rule `voice()` had followed for months and my function quietly broke:
1. **A SUSPENDED AudioContext HAS A FROZEN CLOCK.** On a phone the context starts suspended and
`currentTime` does not advance until it resumes. Scheduling four clips at `currentTime + 0, +0.9,
+1.7, +2.4` against a frozen clock puts every one of them in the PAST by the time the context
actually starts — so they all fire on the same instant, on top of each other, through a 1.3 s
reverb tail. Resume FIRST, and re-read the clock after the decode.
2. **A SECOND CALL LAYERED INSTEAD OF REPLACING.** `voice()` has always enforced one man, one
microphone; `voiceSeq` SET that clock (`voiceUntil`) and never checked it. Two calls close
together = two overlapping lists. It stops the outstanding one now.
3. **CANCELLING A LIST HAS TO GIVE BACK THE TIME IT BOOKED.** The first cut of fix 2 stopped the old
sources but left `voiceUntil` standing, so the replacement queued behind a list that would never
play — measured at **6.6 s of silence** before the call the player is waiting on, and every
redeal made it worse. `seqUntilBefore` rewinds the booking.
4. **THE AMPLIFIER, in drill.js: the empty-court net redealt ON THE SPOT**, which makes the repair
rate the FRAME RATE — and every deal calls the umpire. It goes through the ordinary
between-rounds beat now, so the worst case is one redeal every `drillBeat` seconds: a mode that
limps instead of a phone screaming at you. Pinned by a test that empties the court every frame
for six seconds and asserts at most one call per beat.
**And a measurement lesson worth more than the fix.** The first "it's clean" reading was taken by
counting `fetch`es of `assets/audio/*` — which proved nothing, because `VOICE[]` caches every clip
and a repeat play never fetches again. The honest probe tags each decoded buffer with the file it
came from (patch `decodeAudioData`, pair it with the pending fetch URL) and counts
`AudioBufferSourceNode.start` — then "did two umpire lines overlap" is a real question with a real
answer. Counting requests measures loading; counting `start()` measures sound.
**FOURTH PASS — THE STREAM. Oliver played it again:** *"daniel announcing is maximum annoying"* ·
*"don't let the rush in position just let them coming from the back in steady speed.. try to space
them not too close"* · *"when one combination is done just let the new one in.. in a continuous
stream that increases in speed and difficulty"* · *"the rounds can stay, they should be continuous"*.
**The ROUND survives as the unit; every SEAM around it is deleted.** No walk-on to a mark, no
announcement beat, no between-rounds pause, no deal. Bodies walk in from the far baseline forever,
and a round is a colour combination drawn from whoever is on court — finish one and the next is
drawn in the same frame, off the same rolling population. The bodies you were ignoring a second ago
are the ones being called now, which is a better decoy than the old fixed-batch decoy ever was.
- **Daniel is gone from this mode.** At ~0.8 s a word he needed four seconds to read a list of four,
and with rounds running continuously that is a man talking over the entire game. Each colour has a
PITCH now (`DRILL_COLS.tone`) and a round announces itself as a four-note figure in half a second
(`blip()` in audio.js — a plain triangle with a 12 ms attack, because a hard attack clicks).
`TUNE.drillTones` 0 makes it silent. The `drill_*.m4a` recordings stay in assets: cheap to keep,
and a slower variant of the mode could want them back.
- **`PLACEMENTS` is deleted along with the marks.** Nothing rushes anywhere and nothing stops, so
there are no shapes to place — the geometry now comes from lane choice at spawn plus the depth the
stream naturally strings bodies out into. That is a net DELETION of the fanciest code in the file.
- **Spacing is best-of-9 lane sampling** against every live drill body (it measured only the back
half until 2026-08-16, see that session log), not a retry loop: a hard minimum would silently stall
the stream at exactly the moment it is meant to be getting harder, which reads as the mode dying.
- **A DRILL BODY HOLDS ITS LANE**, and this was the fix the screenshot forced. `updateZombies` homes
bodies onto the player's x once they cross the net — a gore-mode behaviour — so they arrived nicely
spread and then bunched into an unreadable knot right in front of you. Spawn spacing cannot survive
that. Their lane IS the puzzle here (it decides what screens what), so drill bodies walk it straight
in with nothing but the wobble. They still reach you; the lane runs past the baseline.
- **One ramp, off TIME SURVIVED** (`TUNE.drillRampT` 210 s), driving walk speed, spawn gap and round
length together. Time, not rounds won: a round-counted ramp cannot escalate while you stand still.
Speed is stamped per body AT SPAWN, so the ramp shows up as new arrivals outpacing the ones already
walking rather than as the whole court accelerating out from under you.
- **The empty-court watchdog is gone too, and nothing replaced it.** With a stream there is no state
to be stuck in: `if (!d.seq.length) newRound(d)` covers the start, a completion, a double fault and
"everything named got killed by something else". One line replaced a repair path, a cooldown and a
log entry.
- Movement is `updateZombies`' ordinary forward step now (bodies carry a real `zb.v`), so the stride
animates off distance covered for free — the round-based version drove them by hand because they
had marks to stop at.
- Tests rewritten around the stream (27): arrivals keep coming and are spaced *after the walk in*,
lanes are held by identity, the ramp raises speed / arrival rate / round length, a finished round
rolls into the next in the same frame, and being caught costs the point and the court but not the
run. **Two test traps worth remembering**: a drill test that never shoots WILL be caught (~28 s to
walk the court) and the sweep looks exactly like a stalled stream — use `TUNE.eatenR = 0`, the
established soak trick; and never compare `zombies[i]` across frames, because bodies despawn at the
baseline and the array shifts (that read as a 1.5 m lane drift that never happened).
**FIFTH PASS — two cuts.** *"what are the dice dots for?"* -> *"drop them"*, and *"the dodge swipe
should not reset to the middle when firing"*.
- **The die-face pips are gone from the bib and the HUD chip.** They were colourblind redundancy —
a channel so the mode did not rest on hue alone — but they were noise to everyone who can separate
the colours, and they shrank to nothing on a body at distance, which is exactly where the channel
had to work. **This is a known accessibility gap now, recorded rather than solved**: if the mode
ships, the answer is bibs that differ by SHAPE or marking at sprite scale, not dots. A channel that
fails at the far baseline is not a channel.
- **The dodge fix is in gameplay.js, not in the drill**, because it was never a drill bug — see the
new invariant above. It changes the gore mode too, and deliberately.
**SIXTH PASS — THE MODE IS WINNABLE, AND THE DANGER MOVED.** Oliver: *"make the game that it is
actually winable meaning that all required colors are visible on the far side before when a color
combination is shown.. for now no colors that are not in the current combination but occasional
zombies which don't have a color and are converging to the player trying to eat him"*.
Two rules now define the file, and everything else falls out of them:
1. **Every colour on court is IN the current combination.** No coloured decoys at all. A colour you
can see is a colour you will be asked for, and the strip can never name a body that is not there.
2. **A combination is only shown once its bodies are all on the far side** — free, because the BATCH
IS BORN THERE. It is a property of the construction rather than a check that could be wrong: a
batch spawns at the far baseline, its combination is called off it on the same frame, and the
length of the court is the time you are given to work the list.
**The pressure moved to the EATERS**, which is a better home for it: plain unbibbed shamblers on
their own clock (`drillEaterEvery` -> `drillEaterMin`, capped at `drillEaterMax` on court).
Deliberately NOT flagged `drill`, so they keep the gore mode's homing and converge on you while the
bibbed batch walks its lanes. Killing one is never a fault (`drillKill` ignores a body with no bib)
and letting one reach you is the ordinary caught. **The puzzle plays fair and the danger arrives on
its own clock** — much better than a puzzle that cheats.
The batch is the round: kill the last of it and the next walks in on the same frame with its
combination already called. `drillBatch` 3 -> `drillBatchMax` 6 on the ramp, so the list grows AND
the court fills.
**One real bug, and the new test is what caught it: `killZombie` resolved the drill BEFORE splicing
the body.** `drillKill` can draw a new combination on the spot (a double fault re-calls the
survivors; an emptied batch brings in the next one) and it draws from whatever is standing in
`zombies` — so the new combination could name the body that was in the middle of dying. That is a
round asking for a colour which is not on court: precisely the un-winnable state this pass exists to
make impossible. **Splice first, resolve second.**
**SEVENTH PASS — the colour comes off the body, and the mode stops making noises.** Oliver: *"the
colored zombies should also explode like they did before and make the normal splash sound on
destruction.. no bleeps please.. can we make the color as a color circle the walk on? the sign
they're holding is not nice. also remove the ball slicing in this mode.. this leads to unwanted
hits"*. Four things, and one of them was a straight bug of mine.
- **THE BUG: bibbed bodies were dying silently and cleanly.** My hook in `killZombie` did
`drillKill(zb); return;` — and everything below that return is THE DEATH: the blood decal, the
pile, the 36-particle burst, the splat and the headshot crack. The drill resolves after the splice
and **without returning** now. Both halves are load-bearing and each was a bug in turn: splice
first or a freshly drawn combination can name the corpse; don't return, or the body just vanishes.
- **The colour is a DISC ON THE COURT that the body walks on**, drawn BEFORE the sprite. The bib was
a flat quad pinned to a walking sprite — UI stuck onto a character rather than part of the world,
and it covered the artwork. On the ground it sits in the court's own perspective, it is far larger
in pixels than a chest patch at the same depth (measured 12.4 x 4.2 logical px at the spawn line,
24 px wide by the time it reaches you), and it hides none of the sprite. The risk was reading as
another gore decal; the answer is the RING — gore is a soft flat blob, this has a hard dark rim and
a bright inner line, so it reads as painted court marking. `TUNE.drillRingR` sizes it.
**Weakest case is GREEN on grass** — legible thanks to the rim, but it is the one to watch.
- **NO SLICING IN THE DRILL.** A cut ball leaves the corpse and strikes whatever it finds next; in a
mode where hitting the wrong body IS the failure, that is the game choosing your target for you and
choosing it badly. One guard on the `ball.chain` branch. This also retires the old open question
about a ball completing two steps of a combination in one shot.
- **THE MODE NOW HAS NO SOUND OF ITS OWN, deliberately.** Two attempts have been vetoed: the recorded
umpire reading the list ("maximum annoying") and the arpeggio that replaced him ("no bleeps
please"). `blip()` is deleted from audio.js, `drillTones` from TUNE, the correct-kill tick from
`drillKill`. What is left is the game's own vocabulary — a kill bursts and splats like every other
kill — and the strip carries the list. **Do not add a third noise here without a reason**: both
attempts read as the mode talking over itself.
**EIGHTH PASS — the court gets quieter.** *"remove the mob boy - make the normal zombies rarer and
not so close to the color gang"*.
- **`sendBoy` refuses during a drill**, at the function rather than at the call sites (same shape as
its existing `G.interlude` guard, so every present and future caller inherits it). He is one more
body crossing the lanes you are reading colours down, and unlike everything else on that court he
is neither the puzzle nor the threat — pure noise. Measured: 0 boy-frames in 90 s.
Note this means **gore no longer gets mopped in the drill**; the `gore` 40 / `piles` 30 caps are
what keep it bounded, which they already did.
- **The eaters are rarer**: `drillEaterEvery` 7 -> 12 s, `drillEaterMin` 2.2 -> 5, cap 4 -> 3. At the
old rate they were a second crowd rather than an interruption, and a threat you break off to deal
with only works if breaking off is occasional. Measured: 8 arrivals in 90 s where the old numbers
gave ~13.
- **And they enter WIDE of the colour gang** — best-of-9 lane sampling against the bibbed bodies, the
same trick the batch spacing uses. Measured narrowest clearance at entry across 8 spawns: 0.91 m.
They still converge on you afterwards, which is what they are for; this only decides where they
come in, so they stop screening the bodies you are trying to read.
**NINTH PASS — THE ROSTER IS THE ALPHABET, and it deletes a whole subsystem.** Oliver: *"we don't
need colors at all! we already have different zombie types.. so the sequence is the zombies
themselves"*. He is right, and it is better on every axis the colour version was losing on.
The bib, then the ground disc, were both inventions bolted on to give a body an identity **it
already had**. A brute does not look like a runner, the game is built on reading those silhouettes
at speed, and the strip can show the body's own walk frame instead of a swatch — no legend, no
mapping, no new art ever. It also **deletes the accessibility problem outright** instead of
deferring it, because a silhouette is not a hue. `DRILL_COLS`, `drawColourRing`, `drawBib`,
`PIP_FACES`, `blip()`, `drillRingR`, `drillTones` and the seven `drill_*.m4a` call recordings are
all now gone or unused — the mode is materially smaller than it was three passes ago.
TWO RULES keep a type unambiguous and both are load-bearing:
1. **the plain shambler is never in the alphabet** — 'a'/'b' is what the EATERS are, so "not part of
the puzzle" is legible at a glance and needs no marking on anything;
2. **one of each type on court at a time**, guaranteed by the batch dealing distinct types and by
eaters only ever being shamblers. "The brute" always names exactly one body.
**The new failure mode this created, and it is a good example of the class.** Types move at
different speeds, and drill bodies hold their lane — so a body in a lane you are not standing in
walks the full court and off the back untouched. Left as the ordinary despawn that stranded the
sequence asking for a body which **no longer existed**: precisely the un-winnable state the mode
exists to prevent, reintroduced by a change that looked purely cosmetic. Two fixes, both needed:
- **a batch body crossing your baseline counts as CAUGHT** ('GOT PAST YOU'), which is also just
better — the drill is "don't let them through" and now the lanes mean something;
- **the speed spread is narrowed to 1.35..0.70**, not the roster's own 3.2x..0.45x, so a batch stays
coherent. The types still read as different because their real difference is **HP, untouched**: a
runner is one shot and a roller is seven, so the called order still decides how early you must
start on what. Verified: four minutes of simulation, 14400 frames, **zero frames** in which the
sequence named a body that was not on court.
Watch: **a roller in the middle of a sequence is seven aimed serves.** Oliver asked for the roster's
robustness to be kept, so it is — but if a round ever feels like grinding rather than reading, a
drill-only HP scale is the knob, not a change to the roster.
**TENTH PASS — two switches, and the round-boundary bug.**
- **`TUNE.drillOneShot` — tried ON for one playtest, now OFF.** *"one-shot is bad - way too easy -
also we're losing all the nice gore animation sprites"*. The second half is the one that is easy to
miss and worth remembering generally: **a 1 hp body never passes through a wound stage**, so every
skin in `ZWOUND` (arm off, ribs out, still walking) is skipped and the roster's best art never
renders at all. The HP is also what makes the called ORDER matter — a roller is seven serves, so
being told to take it third means starting on it first. Kept as a switch, never a default.
- **THE ROUND-BOUNDARY CARRY-OVER, and it was a real unfairness.** *"the last kill of a game can
carry to the spawn of a new zombie of the next round"*. A ball keeps pace through a kill
(`TUNE.zPierce`) and it travels UP-COURT — which is precisely where the next batch is born. So the
shot that finished your round flew on into bodies that did not exist when you took it and faulted
you on a round you had not been shown yet. **The shot that ends a round is spent** (`ball.active =
false` in drillKill's completion branch, and in `drillCaught` for the same reason: that also
refills the court up-court). Pierce WITHIN a batch is untouched — that is honest occlusion you
could have read; this was not.
- **`TUNE.drillEaterMax` 3 -> 0 (OFF)**: *"remove the fake zombies for now"*. The drill is the pure
puzzle with no second threat at the moment. The mechanism is untouched — `spawnEater`, the wide
entry away from the gang, the never-a-fault rule — so one number brings it back.
The test suite asserts **whichever contract the knob currently describes** (at 0: nothing but the
puzzle ever appears; above 0: they arrive, capped, always plain shamblers) rather than being
deleted and rotting, and the never-a-fault rule is now tested with a hand-placed body so it holds
either way. That is the shape to copy for any other switchable subsystem here.
**ELEVENTH PASS — TRIAGE: the drill finally gets a skill.** Oliver's verdict on the sequence
version was the honest one — *"it feels not interesting enough to keep me playing.. I am really
looking for some mechanic to make this skill based"* — and the diagnosis was that it tested
nothing: the strip was always visible (no memory), the batch was slow (no urgency), the aim was
easy (no execution). A checklist with gore. Settled in discussion: options were hide-the-call
(memory), triage (judgment), clock+streak (score); he picked **triage + streak**.
**ONE RULE: always kill the body that would reach you first.** Nothing is called, nothing shown —
the court is the puzzle. `arrival(zb) = (COURT.L - zb.z) / zb.v` down a HELD lane (that is why
drill bodies hold lanes — it makes the projection honest), and the roster's speed spread means the
answer is not the closest body: a runner at the far baseline beats a roller at the service line.
This is court sense, the most tennis-flavoured skill there is, and it trains the exact
target-priority read the gore mode rewards. The strip, the alphabet, the batch and the eaters are
all deleted — shamblers are first-class stream members now, `pts` 20 up to a roller's 100.
The three design points that make it a game rather than a quiz:
- **WOUNDS ARE FREE.** Only the KILL is judged (drillKill sits behind the kill funnel, wound()
never reaches it), so pre-softening a heavy while it is third in line is legal and eventually
necessary. This is also why `drillOneShot` must stay off: 1 hp bodies delete the planning layer.
- **THE SLACK** (`drillSlack` 0.9 s): two bodies arriving within it are both legal. Without it a
near-tie made the rule a coin flip and a fault read as theft. Tightening it is the difficulty
curve for good players.
- **THE STREAK IS BOTH PUNISHMENT AND PAYOUT.** x1..x5 (`drillMultEvery` 4, `drillMultCap` 5), a
misread resets it and shows OUT OF TURN, and nothing else happens — no sweep, no lost round; the
queue keeps walking. Being CAUGHT is the real setback (sweep + ramp handback), score survives.
Best is banked in localStorage (`drillBest`); the world board can carry it if the mode ships.
**`TUNE.drillMark` (ON for the playtest): a chevron over the queue head.** It exists so Oliver can
check the game's read against his own — and it answers the exact question the mode asks, so it MUST
be off in anything shipped. If his read and the marker disagree often, tune `drillSlack` before
touching `arrival`.
**TWELFTH PASS — the marker stops lying, and the rule announces itself.** Oliver: *"the marker
flips all the time even during a ball being midair.. i am not sure that a player would get the
mechanic intuitively let alone without the marker"*.
**The flip had a specific cause and it was a design bug, not jitter**: with lanes held and speeds
constant, arrival DIFFERENCES never change while bodies walk — the queue only reorders on a kill or
an arrival. So the marker was flipping because it showed ONLY `q[0]` while the rule accepts any
near-tie within the slack: every kill made it jump between tied bodies, i.e. **the marker was
stricter than the rule it was explaining**. `drillLegal()` now marks the whole slack set — bright
head, dim ties — which is stable by construction. Measured: zero head-changes across 12 s of
walking with no kills.
**Learnability, two answers:**
- the rule gets ONE modal, once per run, on the mode's own calm court (the seeded bodies are all at
the far baseline — this is the popup policy's sanctioned case): *CLEARANCE DRILL — DROP WHOEVER
WOULD REACH YOU FIRST · FAST BEATS CLOSE · STREAKS PAY*. "Most urgent first" is not guessable
from a chevron alone; one sentence makes it learnable in the first five seconds.
- **the markers are training wheels that take themselves off**: they hide at streak >=
`drillMarkOff` (8) — the streak IS the evidence you can read the court — and any fault brings
them back. The teacher steps in exactly when you demonstrably need it and out when you don't,
and `drillMark: 0` still kills them outright.
**THIRTEENTH PASS — ZOMBIE TENNIS, and the drill steps aside.** Oliver on triage: *"i think the
normal player does not grasp this"* — the lesson across four drill designs being that **every
invented rule needs teaching, and the only rules that don't are the ones the scene already implies**.
Brainstormed again; he picked the dark horse: real tennis against the dead. One game per opponent,
blood only. *"i can live simple tapping and timing"* — the tap-and-timing scheme, not the slingshot.
**MATCH TEST in the back room (`#match`).** Built ON TOP of the duel machinery, deliberately:
`G.match` runs a PERMANENT interlude — which is already what makes serving tennis-like, hides the
rack, quiets the spawn director and wakes `racketAI`. The one change is in `resolveInterlude`: a
settled point feeds `matchPoint` instead of ending anything. ~90 lines in beats.js for a whole mode,
because the deepest system in the repo (swings, volleys, lanes, the AI) was already built.
- **The score IS the body**: a quarter of the pool per point won, floored at 1 — a love game walks
the racket zombie through every wound stage and the game point fells it (`explode` at the body).
The scoreboard and the corpse agree all the way down. `matchOppHp` 4 + 2 per opponent felled.
- **Its points draw your blood**: `G.hp--`, eaten at zero, the ordinary channel. Four clean points
against you IS the run — `matchHeal` 2 per game won is how you last.
- **Regicide inherited free**: smashing the ball into the body still wounds it, killing it outright
wins the game early (killZombie -> resolveInterlude('P') lands with the court empty; the game is
banked BY HAND there, since advancePoint never saw a game point).
- **THE TIMING IS DRAWN** — his ask: *"what is the possible tap window what is the sweet spot"*.
Two cues riding the mechanics' own numbers so the picture cannot lie: the APPROACH RING (a ring
around the incoming ball that kisses a fixed inner ring exactly at `matchRingSweet` 2.7 m — the
centre of the tapped-points benchmark's window — gold inside `matchRingBand`), and the APEX BAND
(a guide beside the toss, gold across serveApexY ± the same 0.12 m keyPressed scores as perfect,
a white tick riding the live ball). Match-only for now; they would serve plain tennis and the
duel too if Oliver wants them there.
- Verified by autopilot in the browser: 7 games in 90 simulated seconds, opponents 1..8, pools
4/6/8/10, wounds tracking points exactly, heal capping at hpMax.
- **One harness trap that cost a round**: startMatch queues TWO first-sight modals (EXHIBITION
MATCH, then the racket zombie's own) — and a dismiss during a modal's entrance (`m.in < 1`) is
swallowed BY DESIGN. A scripted playtest must clear `G.modal` AND `G.modalQ`, or the world is
frozen and sixty seconds of stepping measures nothing.
**The drill stays in the back room beside it** — triage is a decent idea that failed the
grasp-in-five-seconds test, and the match is now the lead candidate for the middle mode.
**FOURTEENTH PASS — the match plays fair.** Oliver: *"the player is too weak in this mode. also the
killing by smashing occurs too often. i cant really control it. maybe obsolete? also the serving
after the first serve does not happen from behind the groundline"*.
- **`matchPlayerHp` 8** — the match brings its own pool. The gore mode's 4 was never a decision
here, just an inheritance, and a real game has rallies you deserve to lose without the run ending.
- **REGICIDE IS OUT OF THE MATCH** (kept in the duel, where "any weapon counts" is the point of a
sudden-death). In a full game, every hard serve that happened to meet the body was a free kill
nobody aimed. A match ball NEVER damages the opponent now — it passes through like a soft ball and
racketAI plays it — so the wound stages are exactly the scoreboard with no side-channel smearing
them. This also deleted the hp-per-opponent escalation (a deeper pool changes nothing when the
body is pure scoreboard): **the ladder escalates in racketAI instead** — `matchSpeedPer` (+12%
speed per body felled) and `matchFlubKeep` (whiff chance × 0.82 per rung).
- **Serving resets to BEHIND THE BASELINE with real deuce/ad mirroring** — `setupPoint`'s splatter
branch ("serve from wherever you're standing") now excludes the match, which falls through to the
tennis branch. Serving from mid-court where the last rally left you read as a bug because it was
one. Verified: second serve from z 24.3, x mirrored to the ad side.
**FIFTEENTH PASS — the opponent shows up, and the death loop dies.** Oliver: *"still very weak and
slow opponent. it also spawns at places where it is impossible to react to my serve. so i win like
always. in the cases i dont win there is a bug. when trying to serve after i got hurt i get hurt
again and again until i die"*.
- **THE DEATH LOOP was `G.serveNum`.** Tennis's `pointTo` is the only thing that ever reset it — and
the match bypasses pointTo entirely. So the first fault of the match armed "second serve"
PERMANENTLY: every missed serve after it was a double fault -> a point -> blood -> repeat to
death, which is exactly the spiral a just-hurt, rattled player serves into. `matchReady()` (the
match's own point boundary) resets it now, and a regression pins it.
- **The receiver starts every point ON ITS MARK.** It was spawning at a random x and standing
wherever the last rally abandoned it — an opponent that cannot reach the serve is not weak, it is
absent, and every ace against it was hollow. `matchReady` drops it onto the exact receiver
position the tennis branch computes for O (deuce/ad mirrored with the server).
- **The base opponent is a player now**: `matchSpeedBase` 1.4 (the duel's 2.8 m/s lumber was tuned
for ONE sudden-death point, not a game) and `matchFlub` 0.08 (the duel's 0.2 gives away a fifth of
all returns — an opponent that beats itself). Measured with the same autopilot that previously won
seven straight games: **it now loses the match** (2-3, then blooded out in game two). That may
even be too hot — `matchSpeedBase`/`matchFlub` are the two dials, and Oliver's thumb is the judge.
**SIXTEENTH PASS — the REAL death loop, and my serveNum fix was a red herring.** Oliver, again:
*"still when I lost a single point and try to serve again every further tap does hurt me more until
I am dead"*. Reproduced tap-for-tap this time before touching anything, and the previous diagnosis
was wrong — serveNum was A bug, but not THE bug.
**`tossBall` never stamped `ball.lastHit`.** After any rally the zombie touched last, the freshly
tossed ball still said `lastHit: 'O'` — and the fourteenth pass moved the server BEHIND the baseline
(z 24.3, past the passing-shot line at COURT.L + 0.3). So the moment the toss went up, the game saw
"an opponent ball beyond your baseline": *its passing shot ends the duel* -> point to them -> blood.
Every tap = a toss = an instant lost point, faster the more he panicked. Two of my fixes composed
into a death loop: the stale field was harmless while serving happened INSIDE the court.
One stamp in `tossBall` (`ball.lastHit = sv.side` — the toss is the server's ball) fixes every
caller. **The duel and the gore mode carried the same latent bug** for anyone tossing from deep
behind the line after a racket-zombie touch; it never fired only because splatter serving used to
happen from inside the court. Verified in the browser with the exact field shape: lose a rally the
zombie touched last, then eight badly-timed panic taps — one honestly lost point, zero toss-bleeds.
**The lesson for this file: when a report survives a fix, the fix was wrong — reproduce before
re-diagnosing.** The serveNum reset was real and stays, but it was found by reading code, not by
reproducing the report, and the actual loop needed the exact field conditions (a zombie touch, the
new serve position, panic-speed taps) to show itself.
**SEVENTEENTH PASS — "every second ball is unreachable" was the player, not the CPU.** Oliver:
*"the opponent is really hard to beat now.. every second ball is basically unreachable by an
auto-run player.. are there some hidden gestures I don't know about or is it just a very strong
cpu?"* Neither: **the match had inherited gore-splatter's depth-only recovery** (the fifteenth-pass
sidestep-protection rule) by virtue of being `G.mode 'splatter'` — so one wide return pulled you off
centre and you STAYED there, and the next ball to the opposite corner was lost before it was struck.
A player who never walks back to centre experiences every rally as "unreachable"; it read as CPU
strength because the balls kept landing where he wasn't.
- The match recovers to CENTRE now, like tennis — real footwork, and in a rally there is no
artillery lane to protect. Gore splatter keeps the depth-only rule; the sidestep it protects has
no role inside a rally.
- `matchSpeedBase` 1.4 -> 1.25: it was calibrated against the broken recovery, i.e. against a
player who couldn't move. With footwork restored the ladder starts gentler and still climbs.
- For the record, the full attacking vocabulary — nothing else is hidden: timing quality (the ring
and the band), PLACEMENT by flicking/holding a direction during the swing (`doHit`'s aim — deep,
short, wide; wrong-footing is how points are won, the AI recovers slowly on purpose), VOLLEYS
(stepping in to take the ball pre-bounce carries reach x1.4 / window x1.8), and serve pace off the
apex. The placement flick is taught nowhere on touch — worth a ghost-hand lesson if the mode ships.
**EIGHTEENTH PASS — the flick shows itself, the width is a dial, the blood stays on.**
- **THE PLACEMENT RING**: an aimed rally hit (flick or held direction at contact) stamps
`G.placeMark` and a gold ring fades where the shot is going — flick, watch it bite the corner,
learn the cause. Deliberately NOT stamped for unaimed hits: marking the scatter would teach noise.
Fades in 0.8 s; a permanent ring would read as a target to hit rather than feedback.
- **`matchWideBase` 2.1 / `matchWidePer` 0.4 — the opponent's WIDTH is the ladder's third dial.**
Its returns scatter off `aimError x (1 - quality)`, which painted lines from game one ("still a
LOT of unreachable balls"). Opponent one now keeps its returns inside what an auto-run player can
actually cover (measured: 25 returns, all inside ±2.2 m), and earns 0.4 m more width per rung —
line-painting is the LATE ladder. Speed, reliability, width: three dials, all per-rung.
- **THE BLOOD VIGNETTE replaces the temporary border.** The old edge wash flashed 0.3 s and
vanished — between hits the screen said you were fine. Now a radial vignette reads `G.hp`
directly: its depth IS your damage fraction, permanent until healed, and the hit pulse rides on
top. Global (the hp channel is global), not match-only. Same no-HP-bar philosophy, one layer out:
the state is worn, not reported. At 1 hp the frame is properly blood-rimmed and the court centre
stays legible.
**NINETEENTH PASS — pace was the missing dial, and the reachability test finally exists.** Oliver:
*"the vignette can get more pronounced and there are still balls which are basically not reachable
with auto run.. please recheck"* — plus, asked twice and deserving a straight answer: *"did you
write tests for the last issues we encountered?"* **No — the unreachable-balls issue only ever had
config assertions, which is exactly why it came back twice.**
- **Pace, the ladder's FOURTH dial** (`matchOppPace` 0.78, `matchOppPacePer` 0.05/rung): width alone
never fixed reachability because the returns left at ~27 m/s — crossing to your baseline in
~0.6 s, less than auto-run needs from the far corner. Opponent one now returns at ~20 m/s
(measured live: twelve returns, 20.0-20.7), a quarter more flight time; the rungs take it back.
- **The vignette deepens**: `dmg * 0.8` (was 0.5), cap 0.85, and the clean centre SHRINKS with
damage rather than only the rim darkening.
- **The REACHABILITY REGRESSION** (the test this issue always deserved): a headless 90 s autopilot
soak — auto-run, fixed tap window, no placement — asserting ≥10 returns sampled and ≤15% that
never came within volley reach. Sabotage-verified: cranked knobs trip the suite.
**Its honest limit, recorded so nobody overtrusts it**: the autopilot attends perfectly and taps
frame-precisely, so it OUT-COVERS a human — knob-legal-but-hostile values still pass it. It is a
floor against catastrophic regressions, not a proxy for Oliver's thumb; his playtest stays the
real gate for "reachable".
**TWENTIETH PASS — the flight recorder, and the statue with a racket.** Oliver asked to be WATCHED:
*"can you watch me play in the browser pane and see why I loose"*. A passive 20 Hz recorder was
injected (samples score/ball/player/opponent, keeps the last 1.5 s of trajectory before every lost
point, touches nothing) and he played a real match in the pane. Seven losses on record, and the
verdict was unambiguous — **he swung in every single lost point (4-5 swing frames each) while the
ball died 2-3 m to his side, and his own x never moved all game.** His timing was fine. He had no
legs: `autoAssist` keys off `pointer: coarse`, the pane reports a FINE pointer, so auto-run was 0 —
the exact trap the verification recipe has warned about for weeks, now bitten by the designer
himself in live play.
- **`matchAssist` 1.15: the match brings its own legs on every device.** It is a tap-timing game;
footwork is the game's job. Held keys still override (the assist only runs with no manual input).
Pinned by a test that plays with `autoAssist` 0 — the desktop condition — and asserts he runs.
- **The vignette curve is sqrt(damage)**, not linear: the match pool is 8, so hit one was a 10%
alpha nobody can see ("basically invisible"). sqrt front-loads it — first blood reads at ~0.3 and
it still deepens to the cap.
- **The flight-recorder pattern is worth keeping**: instrument passively, let Oliver play, read the
trails. It ended a three-round guessing match in one session — his reports and my autopilot
measurements kept disagreeing because the autopilot had legs he did not.
**TWENTY-FIRST PASS — the short ball, the home-bound intercept, and the portrait vignette.** Second
recorded session: 5 wins and a deuce war to 5-6 (the mode is a contest now), but all seven losses
shared one signature — the ball DIED IN COURT 1.4-2 m short of him while he ran 0.2-0.5 m. Plus his
note *"there seems to be a problem when the ball arrives right from the player"* and *"still no
vignette. just a slight red pulsing from below"*. Three distinct defects:
- **The pace cut created DYING ROLLERS**: slow returns bounce and die at z 18-20 — outside
`intercept`'s near zone, so nothing ever chased them, and the rolled-dead rule gives the point to
the last hitter (the zombie). They also arrive at his feet below legal contact height, which is
the "ball arrives right at the player" feel. `intercept` now keeps an `anyHit` candidate — the
earliest REACHABLE point on his half where the ball is still at hitting height — so the assist
runs FORWARD onto the short ball, which is the tennis answer. (Zone points still win: deep is
better.) Verified: the chase test shows him sprinting z 23 -> 16.4 and meeting the ball at gap ~0.
- **`intercept` computed reachability with `autoAssist`** — 0 on any fine pointer — so a desktop
match's intercept thought nothing was reachable and answered "home" every time: he had legs
(matchAssist) but his destination was always the baseline centre. `assistK()` is the shared
helper now; both the mover and the planner use it.
- **THE VIGNETTE WAS RADIAL IN A PORTRAIT CANVAS.** The circle reaches the corners but arrives at
the left/right screen edges at fraction ZERO — "red pulsing from below" was the bottom edge being
the only place the circle got close. It is FOUR LINEAR EDGE FADES now, hugging the actual
rectangle on any aspect, sqrt(dmg) curve kept, inset deepening with damage. Posed at 6/8 hp: all
four edges carry visible red.
- **Test-measurement trap for the log**: reading `P.z` after the chase loop proves nothing — the
point reset teleports him home. Measure the minimum DURING the chase.
**TWENTY-SECOND PASS — the longer racket.** Third recorded session, and the previous fixes did
their job while exposing the last gap: he ran onto every ball (2.9-3.9 m per point, was 0.2-0.5),
the ball reached him (died at z 22.5-24, was 18-20), he swung every single time — and the closest
approach clustered at a sampled 1.3-1.6 m, centimetres outside the 1.3 m post-bounce reach, point
after point. The assist can deliver you NEAR the ball but not inside a phone-tuned contact radius.
`matchReach` 1.35 widens the contact test in the match only (gore modes untouched — their "tight
contact radius: the tap must be honest" rule stands). A rally mode lives on contact feeling fair.
Note for future recorder sessions: **a 20 Hz sampler overstates closest approach by up to ~0.5 m**
on a 20 m/s ball — the recorder now samples at 60 Hz.
**TWENTY-THIRD PASS — first game won, and the rungs flatten.** Fourth recorded session, his
verdict "better": **he beat opponent one — the first game won in any recorded session — and reached
opponent two,** which then took him apart with gaps of 1.66-2.13 m (60 Hz, accurate) while he swung
at closest approach every time. The ladder gradient is real; the STEP was a wall, because every
rung moves three dials at once (speed, width, pace, plus flub). Per-rung increments softened:
`matchSpeedPer` .12->.08, `matchWidePer` .4->.25, `matchOppPacePer` .05->.035. Opponent one is
untouched — the bases stay; only the slope flattens.
The contact chain is now verified end to end by recorded play: LEGS (matchAssist) -> DESTINATION
(assistK in intercept + the anyHit short-ball chase) -> REACH (matchReach). Each link was a separate
defect found by a separate recorded session. The flight recorder earned a permanent place in the
toolbox: passive 20-60 Hz sampling with pre-loss trails ended four rounds of "still the same" in
four sessions, where autopilot measurement had failed for three passes straight.
**Open, and every one of them is Oliver's call after he plays it:**
1. ~~**A ball that carries through the lane can complete two steps at once.**~~ Largely CLOSED by
the slice removal (seventh pass) — a cut ball picking its own next victim was the main way this
happened. A straight ball can still plow through a body standing directly behind another
(`TUNE.zPierce`), which is honest occlusion rather than the game aiming for you. Watch it.
2. ~~**The tap aims itself.**~~ CLOSED by the second pass above — the snap is gone, you aim.
3. ~~Difficulty climbs on sequence LENGTH.~~ CLOSED by the stream — one ramp on time survived now
drives walk speed, arrival rate and round length together. What is still NOT ramped is how often
the called order fights the geometry; with lanes held, that is a question of which bodies get
called rather than of where they stand.
4. Nothing is wired to tennis scoring, the menu or the run, and the mode has no ending. That is the
next job IF the answer to "tense or noisy?" comes back tense.
5. **A shambler takes two splat-quality hits.** In a mode where every shot is aimed by hand that is
two aimed serves per body, which paces a round more than the order does. Worth checking on feel
whether drill bodies should be one-shot glass.
## Session log: beats.js, the rename, and the build gate (2026-08-11, same day)
Oliver's structural question ("framework? smaller files?") answered with a deliberate NO on both —
the freezes were never a file-size problem, they were a shared-state interleaving problem — and
three targeted moves instead:
- **`beats.js`**: every mode transition extracted from gameplay.js into one file with one stated
rule (one mode owns the court; every `start*` enforces it itself). Pure move, zero behavior
change, verified by the suite passing untouched before and after.
- **This file renamed** `HANDOFF.md` → `PROJECT.md` and audited against the code: the duplicated
swish-cooldown invariant deduped, the stale umpire-triple-tap comment in gameplay.js corrected
(the cheat was removed when the testteam found it), all `HANDOFF` references in code comments
updated. `T.step`/`T.press`, `machEvery` 4, `bossEvery` 10 all verified still true.
- **The Dockerfile gained a test stage** (`node:22-alpine`, `RUN node test.js`): a red suite fails
the image build, so Coolify never deploys a build whose state machine is broken. The final stage
COPYs the stage's marker file so BuildKit cannot skip it.
- The suite grew five regressions mined from this file's own history: the pending-horde-onto-boss-
stage latch (7803c83), the game-30 EPIC-card clobber, the mgT cross-phase serve lock (e75b388),
the duel's payout+heal contract, and the lost-duel set banking with its surge.
- **`CLAUDE.md` added** (auto-loaded every session): the five rules that keep sessions from getting
sloppy — read this file first, run the tests, bump `?v=`, the Dockerfile COPY rule, no deps.
## Session log: the third freeze — court ownership enforced, and the first tests (2026-08-11)
Oliver hit the horde freeze a THIRD time, earlier in a run: **a sudden-death duel started inside a
live horde.** Welded on the right side, no drag (duel's `pretoss` kills steering), no serve
(`G.horde` blocks taps), spawns kept coming. The full chain: kills still score during the SET card
beat (spawn director keeps running there), a game won mid-card re-armed `G.clearing`/`G.afterClear`
while `pendingHorde` was already latched → horde started with a clear parked → mid-horde the gun
mowed the court to an empty frame between spawns → `G.clearing && !blockers` advanced the beat
**mid-horde** → `duelDue` fired `startDuel()`. The watchdog was blind to it by design: its signature
includes `horde.t`, which ticks every frame, so the state always looked alive.
Fixed at the chokepoints, not the call sites:
- `update()`'s hand-clear advance now requires `!G.horde` — a latched clear waits for `endHorde`.
- `startDuel()` and `startHorde()` hard-refuse while any other mode owns the court
(interlude/horde/bossFight). All present and future callers inherit this.
- `splatPoint()` is a no-op during the duel — `resolveInterlude` is the duel's only exit. (A machine
or leftover killed mid-duel used to bank score and could re-enter `proceedAfterClear` mid-duel.)
- **Invariant net** in `watchdog()`: if horde+interlude ever coexist anyway, or the phase leaves
`rally` during a horde, it repairs on the spot and logs `INVARIANT` into `G.missLog`.
**The game now has tests: `node test.js`** — headless Node, real game modules, stubbed platform
(DOM/WebAudio/storage). Seven cases pin the ownership contract, including a scripted repro of the
field freeze and the injected-corruption repair. Run them before touching `gameplay.js` beats.
## Session log: the testgroup round — popups, ghost hand, barrel economy (2026-08-08)
First round of feedback from the small test group (via Oliver), seven items, all landed.
**The popup policy changed: a modal is for CALM courts only.** The report was "popups block
interaction while the game loop continues and the zombies arrive". Measured first, per the
meta-lesson: the world genuinely FREEZES behind a modal (a shambler moved 0.000 m over 3 s with one
up — the invariant holds). What kills is the CONTROL steal: a popup lands mid-crowd, eats the next
tap, and hands the court back with the player's rhythm gone. So every announcement that can land on
a live court is a CARD now (non-blocking): the weapon first-sight modals are deleted outright
(deliverTin's card and popBarrel's new card carry the one desc line that matters), and the
barrel/machine/racket introductions are first-sight cards. The horde popup shows as a modal ONCE
per run (it changes the control mode) and every later set starts on the flash alone — re-teaching
the same rules at the hottest moment of every set was the worst offender. Still modals: sudden
death, the chapter boss, WOUNDS TREATED, test mode — all fire on guaranteed-calm courts.
**The transition howto modal is gone; a GHOST HAND teaches instead.** "Nobody seems to read this."
`drawGhostHand` (render.js) is a drawn pointing hand — canvas paths, UI palette, deliberately not a
sprite — with `drawTapRing` for the contact ripple that makes a mimed tap read as a tap. The apron
demo's old thumb-dot now rides its strokes, and the transition primes `G.idleHintT` so the pull
demo starts miming the moment splatter begins. TENNIS got three lessons on the same hand, each
keyed to the exact failure it answers and reset by doing the thing: idle in the serve stance →
tap demo on the apron (`drawTapHint`); `G.tossDrops` ≥ 2 → the hand rides the toss and PRESSES AT
THE APEX (`drawApexHint` — the timing is the lesson); `G.passStreak` ≥ 2 untouched passes → the
hand presses as the ball closes (`drawHitHint`).
**The opening CPU is beatable in a game or two now.** Testers ground for rounds. `aiFlub` 0.09 →
0.18, `aiQualityMax` 0.85 → 0.72, plus a rubber band: every game the CPU takes adds `aiFlubLoss`
(0.08) to its whiff chance. Tennis mode only exists before the first transition, so these are
purely the opening-match dials (`racket*` covers the duels). **The old "CPU returns ~91.7%"
benchmark is deliberately retired by this** — the expected first-swing return rate is now ~82%
at 0–0 and falls as the CPU loses games.
**A shot barrel no longer yanks the new weapon into your hand.** `popBarrel` restores `G.weapon`
after `grantWeapon` and pulses the new slot instead (`G.rackGlow`, drawn in drawRack) — "the
current weapon should stay active, the new slot should glow briefly". The duel's `deliverTin`
still auto-equips; that one lands on a calm court and IS the prize.
**Switching weapons clicks** (`assets/audio/weapon-click2.mp3`, 0.45 gain — `sfx()` now accepts
names carrying their own extension) **and the live rack tile is unmistakable**: brighter well,
2 px gold frame.
**Barrels ramp with the run** (`barrelRamp`): the gap between barrels shrinks by one every 8
finished games, floored at every game (g0–7 every 3rd, g8–15 every 2nd, g16+ every game). The
late game was "fast tapping until your thumb gives out" once the tins capped; the rack is the
strategic answer, so the run keeps feeding it.
**The umpire triple-tap cheat is deleted** — the test group found it. `T.forceZombies()` remains.
**Third batch, same day — REACTIONS LEFT THE BAND (option C, settled in discussion).** Oliver's
"reactions play after shooting, not when the ball hits" was MEASURED and real: `praise()` fired in
hitServe at RELEASE while the ball flew for another half second. The word is now ARMED on
`ball.praise` and pops at the IMPACT — first body the ball touches (zombies.js contact branch) or
its first bounce (onBounce); a rally hit pops at the racket, where contact and praise are the same
moment. Verified: a 32-frame flight popped its word on frame 32.
Reactions are **world-anchored pop texts** now (`pops`/`popText` in zombies.js, drawn in the world
pass before the un-shake): DOUBLE/TRIPLE KILL and MASSACRE rise off the corpse, RETURNED off the
parry, praise off the impact. Small (Bangers 13), rise-and-fade in 0.85 s, capped at 10.
**The band got ROWS**: flash line 68..90, countdown under it, and `bandTop` floors a card at 96 so
a tall icon card can no longer print over the flash — that collision was the "spamming over each
other" report. **Cuts, per Oliver**: the GAME N flash is deleted (with the whole `pendingGame`
latch — the scoreboard bar prints the number permanently) and the CHANGEOVER flash is folded into
the countdown panel ('CHANGEOVER · RESUMING IN n'). He kept the burst-arm weapon label and the
praise words. **A bottom/above-rack message slot was proposed and REJECTED**: that is thumb
territory — covered by the hand mid-pull and outside where the eyes are.
**Sixth batch, same day — the live card-tuning session (Oliver in the preview pane).** Settled by
looking, together: the card is `CARD_PAD` 20 with the icon boxed to 46×44 and `CARD_TOP` 88 (flush
under the flash row). And the RESUMING countdown left the band entirely: **Bangers 16, gold,
shadowed, floating just above the net tape — no panel, no border.** His call: the countdown
belongs to the court the pause is holding, and the shout face survives any backdrop, which retires
the panel that was added for clay-crowd readability two batches ago. The changeover still names
itself in the same line ('CHANGEOVER · RESUMING IN n').
**Fifth batch, same day — THE BATTERY PASS.** "Drains battery like there is no tomorrow." Measured
first: update() 0.01 ms, render() 0.15 ms per frame — the JS is nothing. The bill is fill-rate ×
frame-rate (a 3x iPhone pushes a ~1080×2400 canvas through the compositor 60×/s) plus 60 Hz wakeups
on static screens. Three levers shipped:
- **The canvas is OPAQUE now** (`dom.js`: `getContext('2d', {alpha:false, desynchronized:true})`)
— render() clears the whole frame to a solid colour anyway, so the compositor no longer alpha-
blends 2.5 Mpx sixty times a second. Free.
- **Calm states run slow** (`calmFps()` in main.js): a SETTLED modal (world frozen, only the blink
moves) runs at 20 fps, the death summary once faded at 24. Live play stays at TUNE.fps — the 60
was a feel decision and the governor deliberately never touches anything that moves.
- **`TUNE.lowPower`** (>= 0.5, T-panel-flippable live): caps RES at 3 — −44% of pixels painted per
frame, THE big lever. `RES` is `export let` now, re-derived in fitViewport; main.js re-fits when
the knob flips. Sprites are 224px sources so RES 3 draws them at-or-below 1:1: softer, never
mushy. **DEFAULT ON since 2026-08-08** — Oliver judged it "looks fine" on his phone. Set
`lowPower: 0` for the full-fat render; if it ever becomes player-facing it should be a settings
toggle, not a TUNE knob.
**Fourth batch, same day — the card went HORIZONTAL, and nothing draws under a modal.** The
stacked icon card ran 102 px tall and its lower third hung over live court; the icon sits LEFT of
the text now (boxed to 58×52 so a wide tin cannot reach the type — and it draws BIGGER than the
stacked layout gave it), so every card is a flat `CARD_H` 62 px at fixed `CARD_TOP` 96, fully over
the crowd. `bandTop()` is gone with it. And a modal owns the whole screen now: the flash and the
card are neither DRAWN under its dim nor TICKED behind the freeze (the card tick moved below the
modal early-return, next to the flash's) — a card raised in the same beat as a popup used to burn
its whole life dimmed-out underneath it, and now it holds and plays after the dismissal.
**Second batch, same day — THE MESSAGE BAND.** Oliver: transient messages need "a definite space
… over the crowd". `AD_TOP()`/`bandTop(h)` in render.js pin the whole family there — below the
scoreboard block, above the ad strip, never on the court:
- **drawCard** anchors in the band instead of centre court (a card at H/2 covered the very play it
announced, non-blocking or not) and its title dropped B(30) → B(22) ("the heading is a little
too big"). FLASH_Y (68) already lived in the band and is unchanged.
- **RESUMING IN n sits on its own PANEL** (the info-bar style) under the flash slot — bare shadowed
text was unreadable over the sunlit clay crowd, a complaint Oliver had been carrying "forever".
It YIELDS to a live card: both draw in the band, and the card is the announcement of the pause.
- **The gesture-teaching strings are deleted from the METER_Y slot** ('PULL DOWN TO AIM…',
'TAP TO TOSS, TAP TO SERVE', 'PULL DOWN: FURTHER = HARDER') — the ghost hand is the teacher, and
the same lesson in two registers was clutter. The slot now only carries STATE the mime cannot:
which weapon the serve fires, the gun-unload line, SECOND SERVE.
- **Ghost hand v2**: 1.4x default scale, hint type F(8) → F(10), and a real silhouette — extended
index finger with a nail hint, three curled-knuckle bumps, thumb out to the side. The first cut
"could be recognized, but…"; the knuckle bumps are what flipped it from mitten to pointing hand.
## Session log: the sidestep, and two type registers (2026-08-03)
Oliver's post-ship playtest. One blocker and seven polish items; all eight landed.
**The blocker: he died at every chapter boss because he could not move.** Two independent causes,
and fixing either alone would have left him rooted. The movement block is `rally`-only (a settled
decision the six boss AI styles were written around), and a boss fight is spent in `pretoss`. On top
of that `canAimServe()` is true through a boss fight, so the one finger was already spent on the
slingshot — there was no gesture left over that *could* mean "move". The answer is a **sidestep**,
not a walk, so the settled decision stands; and the gesture is picked by the DIRECTION the drag
leaves the deadzone in, so nothing about "one gesture means one thing" bends. Both are in the
settled-decisions list above.
Two guards make the dodge worth spending: `hurtPlayer` (the ranged channel) and the contact test in
`updateZombies` (the instant-death channel). Verified by pinning a shambler on the player: alive at
`dodgeI` 0.3, eaten the frame it hit 0.
**The message system was seven unrelated looks; it is two registers now.** Bangers is bundled
alongside Silkscreen and the split is documented above. `drawCard`, `drawModal`'s title, `G.flash`
and the death summary shout; the control hints, the pull/dodge demos, the scoreboard and
`RESUMING IN` stay quiet. The types Oliver had not counted, for the record: the bottom control-hint
slot at `METER_Y`, the `drawPullHint` apron demo, the death summary, and the scoreboard/rack chrome.
**Modals queue now.** `showModal` used to *drop* a second popup opened in the same beat, which is
why the trainer and the weapon had to be crammed into one card after a won duel. `G.modalQ` is four
lines and it retires that hack: the weapon modal, then a real `WOUNDS TREATED` modal with the
`x_cross` first-aid tin over it (the sprite had been preloaded and drawn by nothing since the merge).
**One popup, one rule.** `weaponModal`'s first-sight gate moved INSIDE the function. The barrels
gated themselves and the tin ladder did not, so four weapons re-explained themselves every three
games for a whole run while a barrel weapon explained itself once. `deliverTin` gained the label
flash a barrel already had, or the fifth delivery onward would pay out in silence.
**Ten rack slots, second row on demand.** `RACK_MAX` 4 → 9. Ten tiles across 480px would be ~39 CSS
px on a phone, under the thumb target `rackHit` was widened for, so past five it grows a second row
UPWARD and slots 0-4 stay welded to the bottom edge. `METER_Y` derives from `rackTop()` now, so the
hint line and the apron demo lift with it.
**The apron demo alternates.** `demoSwipe(dx, dy, label)` is the old `drawPullHint` body with the
direction pulled out; `drawPullHint` alternates PULL DOWN and SWIPE TO DODGE on the 1.9s cycle, and
mimes away from whichever edge the player is standing against.
**The transition punches in.** `G.zoom` is a flat 2D scale about a projected world point, applied in
the same place and for the same reason as `G.shake` — a perspective dolly would want `camZ`,
`aimCam` and every projected HUD anchor to agree for the sake of one 1.15s beat. Escalating shake at
3.4 / 4.8 / 8.2 s.
**And the boss win holds.** `TUNE.bossWinT` (3.6s) is its own number so the routine SET card does not
drag with it. `EPIC!` in Bangers, chapter and crowd demoted to subs, and no `GAME n NEXT` line —
the bar under the scoreboard already prints the game number permanently.
## Session log: the second pass on the barrel weapons (2026-08-03)
**The auto-fire jog is gone, and it was one guard.** `gameplay` skipped the pose update in `mgSteer`
mode (`if (!mgSteer && ...) P.pose = moving ? 'run' : 'idle'`), so the pose stuck on 'idle' — and
`render` covered for it by faking a run cycle off `G.idleT`, which animated whether he was moving or
not. That fake is what Oliver saw: *"this hectic run animation... looked odd all the time."* The pose
follows MOVEMENT in every mode now and the fake is deleted, so auto-fire (and the horde, and the
melee racket) uses the ordinary run animation and standing still is standing still. The comment that
justified the fake — "the idle sprite has a racket at his hip AND drawMgRacket puts one in his hands,
two rackets" — was solving a smaller problem than it caused; at sprite scale the overlap does not read.
**The laser, second pass.** Four things, all of them "it must read as light he fired":
- **It draws AFTER the net and every body.** It was drawn before the far-side pass, so the net and
the crowd painted over it — which is precisely why it read as lying on the floor and being *under*
the net. Light in the air goes last.
- **`beamLen` 26 -> 34.** At 26 it stopped a metre past the far baseline and read as being CUT OFF in
mid-court. It now carries into the courtside ads, which is where light should stop.
- **`beamW0` 0.35 -> 0.06** — a POINT at the strings. At 0.35 it left the racket as a 0.7 m bar
hanging in mid-air rather than as something emitted.
- **It DELETES ordinary bodies**, hp irrelevant (`killZombie`, not a wound) — a plane of light that
left a roller man standing made it a worse ball, not a weapon. A BOSS is the exception and takes
`dmgBeam` 8: "they also must suffer severely" but they survive.
- **The look is four nested wedges composited `lighter`** — violet bloom, the weapon's blue, cyan,
white core — each fading along its length. One quad with a gradient was a coloured shape; layers
sharing an axis and brightening toward the centre is what reads as sci-fi.
**Rocket:** speed 17 -> 38 and a real JET plume. The plume is laid down tight ON the flight line
(`jet`, spread along this frame's step, near-zero velocity, additive hot-white -> orange -> grey)
instead of scattered sparks with their own velocities, which looked like a firework.
**Beacon:** a finer trail (`trail` 1 at `trailR` 0.035, one-pixel motes) and a much brighter ball
(`glow` 3.2, a two-ring additive bloom and a fat white core), so the BALL is the bright thing and the
trail only says where it has been.
**The bug in the middle of all that:** `firePellet` stamps a whitelist of fields onto the round, and
`jet`/`trailR` were added to the WEAPONS rows without being added to that list — so the rocket flew
with no plume at all and nothing errored. **Any new pellet field has to be added in both places.**
## Session log: fire, ash, and the third laser pass (2026-08-03)
**A BURNT BODY DOES NOT BURST.** Fire kills by cooking, so there is nothing left to spray: a body
that dies while alight — flamethrower or flame ball — leaves **dark grey ash** instead of blood. Same
decal, same mound, same particles, all recoloured and a little smaller, plus a puff of smoke and a
dull thud instead of the wet splat and the headshot crack. `killZombie` reads `zb.burnT > 0 ||
zb.ashes`; **the flame BALL has to set `zb.ashes` itself**, because that kill happens before the body
ever catches. `gore`/`piles`/`parts` all carry an `ash` flag and render off `ASH_COLS`.
**The flamethrower is a lighter, not a gun.** `dmg: 0, hp: 0, pierce: true` — it does NO impact
damage at all and only IGNITES; the burn does the killing, so a lit crowd walks a few steps and goes
down together. Reach `life` 1.15 s x `speed` 23 = **26 m against a 22.8 m court**, so it crosses to
the far baseline. Watch the guard in the pellet loop: `hp -= 0` fell straight through to
`killZombie`, which made the flame cone delete the crowd on contact — a machine gun that happens to
be orange.
**Every burst weapon had no progress bar, and the cause was the spend.** Arming spent the tin
immediately, so the rack entry vanished — and the countdown bar draws on the slot holding `G.gunId`,
which no longer existed. The tin is spent when the burst ENDS now (`endBurst`), so the icon, the
count and the bar are all there while it runs. **Every path that ends a burst must route through
`endBurst`** — the duel, the boss fight and death all force `G.mgT = 0` on their own, and any one of
them missed would hand out free ammo. The soak asserts `gunId` is never set with `mgT` at zero.
**Auto-fire movement, again.** The previous fix set the pose from `moving`, but `moving` was
`mx || mz || assist`, and finger-steering zeroes `mx`/`mz` — so a player sliding left and right under
auto-fire still counted as standing still and never ran. `mgMoving` is in that expression now.
**The mg racket is GONE.** It was a procedural racket drawn over a sprite that already holds one:
straight up-court it projected onto the player's back as a floating oval, and offset to the side it
just stuck out. Oliver: "can we please just hide it?" The pellets streaming up-court are the
feedback. `drawMgRacket` is deleted, not commented out.
**(Previously) the oval on his back was the mg racket.** `drawMgRacket` put its head at `(P.x, 0.95, P.z - 1.35)`,
which at this camera projects almost exactly onto the player sprite. Offset by `P.facing` it reads as
a racket held out beside him.
**A third array had the empties-under-the-iterator bug.** A rocket blast can bank the last kill of a
game, and the beat that follows (a duel, a chapter boss) does `pellets.length = 0` — so the pellet
loop's next index read undefined and the frame threw. `shots`, `pellets`, `extras` and the body loop
all carry the same `if (!x) continue` guard now. **Any array iterated in `updateZombies` that can
contain something whose death advances the game needs it.**
**A gradient that ends at alpha 0 hides the thing you are aligning.** The laser's far corners were
pinned correctly from the first attempt, but each wedge layer faded to fully transparent along its
length — so the beam became invisible before it reached the pinned edge, the VISIBLE end sat well
short of it, and moving the geometry appeared to do nothing twice in a row. The far stop is lit
(0.62 of the layer's alpha) now, so the visible edge and the pinned edge are the same line.
**The beam falls off with distance** — brightest at the strings, thinning down the court, because
that is what a beam travelling away from you does. The far stop is 0.32 of the layer's alpha: enough
depth to read, never zero, so the visible end stays the pinned end.
**The laser's far edge is pinned in SCREEN space.** Running out to `beamLen` on the ground landed it
on the ads' TOP line; it should die at their middle. `c3.y = c4.y = proj(0,0,-6).y - AD_H / 2` — the
x's keep their perspective, the y is pinned. That holds in both orientations and leaves `beamLen`
free to be the KILL range instead of a number tuned to one camera.
**Weapon sounds** are a `sfx` field on the WEAPONS row: `thump` (beacon, `boom`), `swoosh` (rocket,
`whoosh`), otherwise the gun's every-other-round tick.
## THE BLINKING BODIES, and why a missing frame is worse than a missing sheet (2026-08-03)
Oliver: "on iphone the zombies were blinking in and out of existence". **`drawZombie` validated its
frame count against the LAST frame only** — `if (!SPR['z_' + v + N]) N = 2` — so a set with, say,
`z_h2` missing but `z_h4` present kept `N = 4`, the walk cycle stepped onto a name that had never
loaded, and `drawStatic` silently drew NOTHING. The body vanished for that frame of the cycle and
came back on the next one, forever. A single cached 404 (this project's oldest trap) is enough.
`if (!SPR[name] && SPR['z_' + v + '1']) name = 'z_' + v + '1';` — hold frame one. **A body that stops
animating is a bug you can see and diagnose; one that flickers in and out reads as the game
breaking.** Every silent `SPR[x] ? x : fallback` in this file deserves the same suspicion.
**And no `ctx.filter`, anywhere.** The cut line was briefly the sprite redrawn clipped to a band and
flattened to one colour by a canvas filter, which put it exactly on the silhouette. On an iPhone the
line did not draw AT ALL. Whether that was the filter being unsupported or leaking past its
`restore()`, a rendering feature that fails silently on the target device is not worth a pixel of
polish — the line is a plain bar now and crosses the odd gap between the legs, which is the trade.
## The laser plane, and cutting bodies with it (2026-08-03)
Settled with Oliver in discussion, then built. **The beam is a real plane at `TUNE.beamY` (1.15 m)**
that holds that height all the way out instead of sloping to the ground — that single change is what
makes the rest possible. Sloping, it lay flat on the grass across the whole far half, so there was
nothing for it to cut exactly where the crowd stands.
- **`beamLen` is the DRAWN length now, not just the kill range.** It used to be solved every frame so
the far end landed on the middle of the ad strip, which made it a knob that visibly did nothing.
One meaning beats one clever behaviour.
- **`TUNE.beamBand` is a live A/B between two looks**, flippable from the T panel on the phone.
**1 (default) = the BAND**: drawn the way the ball's aim trajectory is drawn — a path down the lane
whose width comes from each point's OWN projection scale, so it narrows into the distance like
everything else on the court, with a shadow on the lawn to pin it there. **0 = the CONE**: the solid
widening triangle. The cone IS a true projection, but it grows in world width (0.06 m -> `beamW1`),
so on screen it opens out while the court recedes — and the eye reads that as a flat shape stuck to
the screen. Oliver: "it does not look correct perspective wise".
**The KILL follows whichever look is on** (`beamBandW * 2` vs the cone's taper) in BOTH `fireBeam`
and `drawBeamCuts`, or the comparison would be a lie — measured, the cone takes 9 of 9 bodies in a
lane and the band 5 of 9.
- **THE PLANE IS OPAQUE, and that is what settled it.** While it was translucent, its opacity and
the over/under read were the same dial pulling in opposite directions: readable enough to show the
split and it veiled the whole cone, subtle enough to look right and you could not tell above from
below at all. Solid colour ends the argument — a body above the plane is drawn on top of it, a body
below is simply hidden behind it. **Depth is carried by BRIGHTNESS** (full at the strings, half at
the ads) rather than by alpha, which is why it still reads as receding. `BEAM_EDGE`/`BEAM_MID`/
`BEAM_CORE` are the three colours. The trade is that you cannot see the crowd through it.
- **The fill is TWO gradients**, matching the reference Oliver sent: a horizontal one across the
width (near-white down the centre line, falling to the weapon's blue at the edges) and a depth one
along the length. Canvas has no 2D gradient, so the wedge is filled in eight slices along its
length, each with its own across-gradient at that slice's depth alpha — enough slices that the
seams vanish. The two long edges are STROKED: the reference reads as a plane because its edges are
drawn, not merely where the fill stops.
- **One flat translucent triangle**, apex at the player's CHEST (`beamOriginY` 1.25 — emitted from
the racket head at 2.2 m the apex hung in the air and read as detached), opening out down the
court, alpha falling with distance. It was four nested wedges composited `lighter`, and that
layering WAS the banding: additive light blowing out to white where layers overlapped. Flat alpha
also means a body under the plane is TINTED rather than brightened, which is what makes the cut read.
- **Bodies are sliced by it.** `drawBeamCuts` runs after the beam: for each body in the cone, the
part above `proj(zb.x, beamY, zb.z).y` is redrawn clipped, so it sits ON TOP of the plane while the
part below stays tinted underneath. `drawZombie` mutates nothing, so drawing it twice is safe.
Measured: the plane takes a shambler at 64% of its height (sternum), a brute at 43% (belly), the
roller man at 36% (thigh) — bigger bodies visibly take it lower.
- **The cut line follows the SPRITE, not a rectangle.** A `fillRect` spanned the body's bounding
width and so crossed the empty gaps between the legs and under a held-out arm, reading as a bar
lying behind him. The line is the sprite itself, redrawn clipped to a thin band and recoloured flat
by `ctx.filter`. Thickness scales with `h * s²` so it is a hairline at the ads and a real band at
your feet — the camera's scale only spans ~1.5x across the court, so a linear falloff was invisible.
- **Getting a filter to a specific colour is a MEASUREMENT, not a guess.** The chain has to leave a
MID grey for `sepia` to tint: `brightness(0) invert(1)` goes to white, and sepia adds almost no
chroma to white while saturating a colourless pixel does nothing — the first attempt rendered
#a0b4c3, a grey. `brightness(0) invert(0.45) sepia(1) saturate(1600%) hue-rotate(185deg)` renders
(32,154,255) against the laser icon's (29,153,232). Probe candidate chains by filling a rect and
reading the pixel back; do not eyeball it.
- **`HAS_FILTER` is feature-detected** and the line falls back to a narrow bar without it (older
WebKit has no `ctx.filter`).
- **The cut pass is BOUNDED** (`CUT_MAX` 8 nearest bodies, `CUT_MIN_PX` 14): it draws each body twice
more and the second pass runs through a filter, which is slow on iOS, and it runs every frame you
are AIMING rather than only on the shot. Measured on desktop: 0.27 ms/frame with 20 bodies against
a 16.7 ms budget.
## T.laserScene() — the tuning playground
`T.laserScene()` in the console (or `#laser` on the URL) poses six bodies of different types at known
depths with the laser armed and a pull held. `T.laserFire()` shoots it; `T.laserScene()` re-poses.
**It does NOT pause, and that is the point.** The first version froze the world so the picture would
hold still — and then every value changed in the T panel did nothing visible, because nothing
redrew. Oliver: "tuning the parameters does not change the view (because it's paused?)". The bodies
are held still individually (`v = 0`) and spawning is off instead, so the picture is stable while the
render loop keeps running. **Never pose a tuning scene by halting rAF or pausing** — a still frame
you cannot change is worth nothing.
**A boolean living in TUNE must be tested with `>= 0.5`.** The panel renders every TUNE key as a
number input stepping by 0.02, so one nudge on `beamBand` set it to 0.02 — still truthy — and the
renderer silently switched variants under him mid-tune.
The laser's shape was tuned this way and these are Oliver's numbers: `beamY` 0.95, `beamLen` 23,
`beamW0` 0.08, `beamW1` 3.1, `beamOriginLift` 0.44, `beamBorder` 0.18.
**`beamY` and `beamLen` interact** — a lower plane meets the ad strip at a shorter distance, so
dropping one wants the other shortened.
## T.pause(true) — freeze the world, keep drawing
`T.pause(true)` stops `update()` but keeps `render()` running, so a value changed in devtools shows
on the next frame while the scene stands still. The older recipe halted rAF
(`requestAnimationFrame = () => 0`), which freezes the PICTURE too and is no use for judging how
something looks. `T.pause(false)` runs again.
## TEST MODE — triple-tap the scoreboard
A sandbox for trying new content without playing twenty games to reach it. Three taps on the
scoreboard (main.js; the umpire's neighbouring triple-tap cheat is deleted) toggles
`G.testMode`; three more turns it off, and a SINGLE tap is still the miss-log overlay.
- a fixed MEDIUM wave: `testZMax` 8 bodies, `testZSpeed` 1.15 m/s, `testSpawnEvery` 1.6 s — it
overrides the difficulty director rather than editing it, so the sandbox is the same whether you
opened it in game 1 or game 40, and `G.runGames` keeps climbing underneath.
- `testLevel` 20 is the roster level `pickVariant` pretends to be at, so every body type can appear.
- every barrel weapon on the rack, TOPPED BACK UP each frame (`stockTestRack`), so committing a
burst — which spends the whole tin — leaves it available again a moment later.
- a barrel every `testBarrelEvery` seconds, so the pickup itself is testable.
**The third tap must not toggle `logShow`.** Taps one and two already toggled it twice, so it is
back where it started; toggling again in the triple branch leaves the miss-log overlay switched on
behind you every time you enter the mode.
## Verification recipe (the regression net)
Drive the game deterministically via `window.T` with `javascript_tool`. The Browser-pane eval
context reports `document.visibilityState === 'hidden'`, so RAF is paused; halt it anyway with
`window.requestAnimationFrame = () => 0` and step with `T.step(1/60)`. `T.press('Space')` = tap.
Clear the intro first: `document.getElementById('intro')?.remove()`.
- **Two consecutive `render()` calls do NOT produce the same frame**, so "draw it, change one thing,
draw again, diff the pixels" silently measures nothing. `render()` mutates camera state as a side
effect (`aimCam += ((G.aim ? 1 : 0) - aimCam) * 0.15`), so the whole scene shifts slightly between
grabs and the diff lights up everywhere — it reported a laser beam extending 16 px above where its
own geometry could possibly reach. To check WHERE something lands, recompute the projection
directly (`T.proj(...)`) and compare numbers; keep pixel-diffing for "did anything at all change".
- **The module-refresh list MUST include the VERSIONED entry point.** `index.html` loads
`src/main.js?v=N`, and `fetch('src/main.js', {cache:'reload'})` does not bust that URL — so main.js
alone stays stale through a reload while every other module is fresh. Read the src off the page:
`document.querySelector('script[type=module]').getAttribute('src')` and fetch that too. Symptom: a
change in main.js appears to do nothing at all, repeatably.
- **You MUST set `T.TUNE.autoAssist = 1.15` first** — the Browser pane reports `pointer: fine`, so
the mobile assist is off and every ball reads as "never reachable".
- Force splatter with `T.forceZombies()` then ~600 steps (in splatter it forces a HORDE instead).
- `T.zombies`, `T.spawnZombie(x, z, 'a')`, `T.TUNE.zMax = 0` to stop spawns during tests.
- Also exposed: `T.explode`, `T.splatPoint`, `T.startDuel`, `T.resolveInterlude`, `T.deliverTin`,
`T.flickTarget`, `T.showModal`/`T.dismissModal`, `T.render`.
**Benchmarks that must keep holding** (re-run after any physics/aim change):
- **RETIRED 2026-08-08**: the ~91.7% CPU return rate. The opening CPU was deliberately softened
(aiFlub 0.18 + aiFlubLoss rubber band, see that session log) — expect ~82% at 0–0, less as it
drops games. The differential method below is still how to prove "no unintended regression".
The old record, for the archaeology:
CPU returns ~11/12 serves (91.7%). Recent 150-sample runs: 90.7%, 89.3%, 90.7%, 88.7% (±2.5%).
Watch this — the last few sit under the bar rather than scattered around it.
**The harness for this number was never written down and I could not reconstruct it.** A rig that
counts "did the CPU get a racket on the served ball" scores 100/100, because after a flub the CPU
gets another swing at the SAME ball (`aiSwingCool = 0.5`) and connects — 91.7% ≈ 1 − aiFlub, so the
recorded number must count only the FIRST swing opportunity, or only returns that land in.
Until someone writes the rig down, prove "no tennis regression" **differentially** instead: run
your rig, `git stash` the change, reload the modules, run the identical rig, compare. Equal
numbers on the same rig is the real claim; a bare percentage against this line is not.
- 30 human-tapped points (tap when the incoming ball is 2.2–3.2 m away) = **50 hits / 0 whiffs**.
This one DOES reproduce exactly — drive it off `G.missLog` (HIT vs MISS counts).
- A 26-game state-machine soak with bombs/bursts/fire must finish with no exceptions, no stall,
`mgT` back to 0, and gore/piles arrays bounded (gore caps at 40, piles at 30).
**Two ways a soak lies to you**, both cost me a run: force-spawning zombies during `G.clearing`
makes the hand-clear un-completable and reads as a 30-minute stall; and reviving the player after
each `eaten` inflates gore past its cap, because the death push (`zombies.js`, the `eatenR` hit) is
the one uncapped `gore.push` in the file — fine at one death per run, not at 260. Respect
`clearing`, and either end the soak on death or set `TUNE.eatenR = 0` to make him untouchable.
- **Never trust a single sample.** aiFlub is 9%; a 20/24 run is normal noise. Use 150.
## Asset pipeline
**WHICH TOOL FOR WHICH JOB — read this before opening any `_*.py` (2026-08-14).** A fresh session
reaches for `_regen.py` first, because it is the biggest file and the oldest name — and for most jobs
it is the WRONG tool, superseded by the reference-sheet flow. Route by job:
| The job | The tool |
|---|---|
| A whole opponent family (31 sprites, one character) | `_famroll.py` -> `_zsheet.py` — see "THE ZOMBIE FAMILY PIPELINE" below. **Never `_regen.py`**: nine separate rolls is nine chances for the character to drift, which is why the family pipeline exists. |
| Rebuild the pose reference | `_famref.py` — composed from disk, no API call, gated. |
| A PLAYER RUN CYCLE | `_vidroll.py` — a video model, not a still one. See "THE RUN CYCLES CAME OUT OF A VIDEO" below. `_runroll.py` still rolls the single frame you film FROM, and still serves the opponents' pairs; it cannot produce a gait. |
| A stride twin (legs frozen) | `_stride.py`. |
| THE SAME CHARACTER IN SOMEONE ELSE'S CLOTHES, every pose at once | `_frontroll.py` — famroll's trick pointed at the tennis poses: image 1 is the ten `o_*` frames composed from disk, image 2 is the body to dress them in. Two calls made the whole netplay family. Ten `_regen` batches would have been ten chances for the man to drift. |
| Single-sheet remaster, boss roster, any non-family sprite | `_regen.py` + `_regen_batches.json` — the only jobs it is still for. |
| Standalone art (logo, panels) | `_key.py`; coarse legacy sheets, `_slice.py`. |
**And when installed sprites look WORSE than the `_refcheck/fam_*.png` sheet they came from, the
suspect is the CUT, not the roll** — the sheets on disk are the evidence, and a re-cut is free (see
"Only ink votes" below). Seven families shipped pixelated for two days because every fresh look
started from "the generator drew it badly" instead of diffing sheet against sprite.
**NEVER SAY "TRANSPARENT" TO THE MODEL — name the flat hex (re-learned the hard way 2026-08-14).**
The model has no concept of transparency: the word summons a PAINTED CHECKERBOARD background, and
"cuts out later" invites grey improvisations. Every background/bed instruction names ONE concrete
flat colour ("ONE FLAT solid #FF00FF across the entire canvas") and never mentions cutting,
keying or transparency. This rule already lived in `_regen.py`'s CANVAS block; a freehand one-off
prompt bypassed it and cost three rolls — REUSE THE CANVAS BLOCK (import it from `_regen.py`)
instead of re-writing background prose in scratch scripts.
**STRING BEDS ARE MAGENTA ON PURPOSE, and the cutter must be told (2026-08-14).** Prompts ask for
#FF00FF racket beds so they cut to transparency (the famroll rule). But `_fine.key` REPAINTS
enclosed magenta pale grey — protection from the era when magenta beds were an accident — which is
how the tennis opponents shipped holding solid white paddles. `key()`/`main()` take `bed=True` now
(_runroll and the opponent-refresh cuts pass it; `_zsheet` has its own equivalent). New cut path
for a sheet whose beds are meant to open: pass the flag, or the paddles come back.
1. `generate-image` skill (needs `OPENROUTER_API_KEY` in the gitignored repo `.env`; run with the
scratchpad venv python — system python lacks `requests`). Passing the existing sheet as
`--input` (edit mode) nails the style and is how every matching sheet here was made.
2. Ask for a **magenta background** and, for sheets, "at least 150px of empty magenta between
frames" plus "the prop must stay inside its own frame".
3. `python3 assets/_slice.py sheet.png out1.png ...` — chroma-key on the corner colour, magenta
de-fringe, column-gap segmentation, common-scale per sheet, `IDLE_TARGET_H=112`. `--largest`
keeps only the main blob.
4. Standalone art (logo, popup panels) uses `assets/_key.py` instead: chroma-key + crop to the
content bbox + optional downscale, no frame splitting, no forced height.
5. Register new names in `render.js`'s `SPR` preload array.
6. **New sprite not showing up? Bump `SPRV` in render.js.** Sprite loads go through
`assets/<n>.png?v=SPRV` for the same reason index.html versions main.js: python http.server sends
no cache headers, so the browser caches sprite responses heuristically — **including 404s**. Add a
name to the `SPR` list before its PNG exists, load the page once, and that browser treats the
sprite as permanently missing while `drawZombie` silently falls back to another design. Bump SPRV
alongside `main.js?v=N`.
7. **When a scripted `str.replace` edit is meant to rewire logic, ASSERT it matched.** A silent
no-op replace left `drawZombie` on its old hardcoded boss names while everything else had moved to
the BOSS_ART table, and the built-in fallback then hid the failure perfectly — all four boss
designs rendered as design 1 and nothing errored. Cost a full debugging round. `drawImage`
instrumentation is what finally settled it:
```js
const o = CanvasRenderingContext2D.prototype.drawImage;
CanvasRenderingContext2D.prototype.drawImage = function (img, ...a) { log(img.src); return o.call(this, img, ...a); };
```
8. **NEVER let two sprite names differ only by CASE.** macOS is case-insensitive: writing
`assets/z_B1.png` for the chapter boss silently OVERWROTE the 'b' shambler's `assets/z_b1.png`
(and `z_B2` ate `z_b2`), so a 3-metre boss started showing up in the shambler crowd and `git`
reported the shambler frames as "modified" rather than reporting a new file. `ls` shows only the
ORIGINAL lowercase name, which is why it is easy to miss. Boss frames are `z_boss*` now. Use
all-lowercase, whole-word names.
9. **The prompt sets the ART RESOLUTION, and it is easy to set it wrong.** Sheets asked for "big
chunky pixel blocks about 8 screen pixels across" and "flat blocks of colour, no shading" came
back coarse — the generator obeys that literally, and Oliver reads it instantly as "waaaay lower
resolution than z_mach2". Ask instead for HIGH resolution: *"build each figure out of roughly 90
to 110 pixels of height instead of the reference's 40"*, then enumerate what to spend it on —
teeth, sunken eyes, individual fingers, seams, hems, folds, frayed edges, laced shoes, four or
five shades per material with hatched shading. `assets/_regen.py` carries the working style block.
10. **`assets/_regen.py` + `_regen_batches.json` redraw a whole character end to end**: reference
sheet from the sprites in the repo -> generate -> `_fine.py` -> colour-match onto what it
replaces -> install, with three automatic retries. It REFUSES to install a bad sheet: wrong frame
count, any figure touching a canvas edge (that is the bug that clipped z_f1), or a redraw that is
not at least 1.25x finer by inner-edge density. `--list` shows the batches, `--all` runs them.
11. **`assets/_fine.py` is the slicer for detailed art.** `_slice.py` resamples NEAREST, which is
right for chunky art and shreds fine art at a non-integer ratio. `_fine.py` snaps to the
generator's own art-pixel grid, common-scales in ART pixels off a chosen `--anchor` frame (so a
smash with the arm overhead stays legitimately taller), quantises back to the sheet's palette, and
de-fringes the key colour twice — the generator does not always return a pure magenta and the halo
survives as a rim on a green court.
12. **Two-row "style reference / redraw this" input does NOT work.** The model returns both rows and
invents a background. Describe the target detail level in words instead.
12b. **`_regen.py` has THREE style blocks now, and picking the wrong one costs rolls.** The shared
block used to be one constant that always said *"keep every pose, every colour EXACTLY as in the
reference"* — correct for a remaster, and it fights every word of the subject for anything else.
- default (`STYLE`): remastering a sprite that already exists.
- `"newart": true` (`NEW_STYLE`): a NEW character drawn in the reference's style. Drops the
fidelity clause and says explicitly not to copy the reference's body or outfit.
- `"state": true` (`STATE_STYLE`): the SAME character in a new state — wounded, mid-throw. This is
the one that is easy to get wrong, because `newart` looks right and isn't: it licenses a repaint,
and it got one. Five wound sheets came back with living TAN skin and a redesigned shirt while the
healthy frame stayed a grey-green corpse, and the coach's navy tracksuit came back mid-blue
(torso RGB blue channel 111 -> 138). `STATE_STYLE` pins colours, clothing and build to the
reference and states outright that he is a corpse, never pink or tan.
**A brand-new sprite name must exist on disk before its batch runs** — `run()` measures `density()`
of the file it is about to replace, and `colour_match` opens it. Seed each new name by copying a
body of the right size and facing, and use `check: same`/`loose` so `colour_match` is skipped and
the seed's palette is NOT inherited. The trap in that: a batch that silently fails leaves a perfect
copy of another character behind, which renders as working art. Assert every new PNG actually
differs from its seed before shipping (the scratchpad `check_seeds.py` does exactly this).
13. **`assets/_derive.py` is where a frame that must MATCH another frame is made.** Three of them
live there now: `p_bounce2` (a per-column shear of `p_bounce` — the free arm bends, nothing else
moves), `p_toss` (the drawn-in ball cut out, canvas height KEPT so drawChar keeps his scale) and
the runner's hip registration. "Same drawing, one small difference" is the one request the
generator cannot fill: it returns a different man and the swap reads as a flicker, which is the
defect you were fixing. The bounce/toss steps are idempotent; the runner step is `--runner` and
must run exactly once per generated set, because it mirrors and it pads.
14. **A new pose needs a STANDING frame in the same sheet, as frame 0, thrown away after.** A sprite's
drawn size comes from its total height, so a sheet of nothing but arms-overhead poses has no
honest scale to anchor on and the man comes out short. `_regen.py`'s `ref` field does this, but
only when `ref_only` is unused — with `ref_only` set, list the throwaway as an extra sprite
(`p_pull0`) and delete the PNG afterwards.
15. **NOTHING IN A SPRITE MAY BE PINK, MAGENTA, VIOLET OR PURPLE.** The chroma key does not merely
match the corner colour, it deletes every pixel where red AND blue both beat green by 40
(`_key.py`: `min(r, b) > g + 40`, and `_fine.py` de-fringes the key twice). So a "vivid magenta
drum" and a "magenta laser pistol with a violet coil" both came back as hollow outlines — 19% and
15% solid pixels against 50-56% for the icons drawn in other colours. Measure `alpha > 128` as a
fraction of the box if a sprite looks thin; that is the whole diagnosis. **Pink can only ever be
PROCEDURAL** — the weapon barrel is grey steel with yellow chevrons in its PNG, and every bit of
its pink is the halo `drawBarrel` paints around it, which never goes near the keyer. State the
forbidden colours in the prompt explicitly; "make it pink" is a request the pipeline cannot fill.
Known generator quirks, all still true:
- It sometimes ignores the magenta (harmless — the key uses the corner colour).
- It draws near-identical animation frames unless you describe strongly contrasted poses.
- **It adds props the A frames don't have** (a dragged pole on every brute B frame, a mop head). If
the prop is a separate blob, keep the largest connected component; if it touches the body, cut on
column density — a torso is dense, a dragged pole is a thin diagonal of ~7 px per column.
- **If frames merge** (a handle bridging the gap) `_slice.py` reports "found 1 frames". Re-prompt
for wider gaps first; if that fails, cut at the true column gaps plus the thinnest column inside
the merged pair, then common-scale.
- **Colour-match any regenerated frame** of an existing pair before shipping: fit a per-channel
linear map onto the original's mean and sd, then verify with a mean-difference number.
## THE ART PIPELINE, corrected — what a day of chasing the generator taught (2026-08-12)
A whole day went into sprite consistency and most of it was spent solving the problem in the wrong
place. Read this before touching `assets/_regen.py`, `_fine.py` or `_master.py`; it will save the
same day.
### The boundary that everything else follows
| what it is good at | who does it |
|---|---|
| identity, pose, composition | the generator, **given reference images** |
| exact colour values | the pipeline, **deterministically** |
| layout accidents — clipped limbs, grids, merged frames, wrong frame count | the gates |
Six gates were built in one day trying to make a generator do arithmetic. It cannot. The proof is
unambiguous: the brute's serve sheet came back with his build, his garments and his DARK TONES
pixel-identical to the reference — `(56,56,32)` in both — and his big pale shirt two shades
brighter, `(200,200,184)` against `(176,176,152)`. The model was looking straight at the character.
Every call re-renders, and re-rendering re-lights. **No prompt fixes that; `match_to_anchor()` does,
in one pass, retroactively, on art already on disk.**
### A REFERENCE BEATS A DESCRIPTION. Every time, at every level.
This is the single highest-value rule here, and every failure of the day was a violation of it:
- The racket zombie's play sheet was generated from `[z_c1, o_idle1, o_run1, o_swing1]` — the
character as an IMAGE, the poses as IMAGES — and landed first time.
- The brute's three sheets each restyled from the master with the character in **words**, so each
one re-derived "pale grey-green work shirt" from the same paragraph and the game got three
different brutes.
- Wound stages only started accumulating damage when stage 2 referenced stage 1's SHEET instead of
being told in prose that the arm was still missing. The arm kept coming back because the reference
picture had two arms in it, and a picture beats a paragraph — including when the picture is wrong.
Corollary: every corrective sentence in a prompt is compensating for a reference you did not supply.
When a prompt grows a third "do NOT" clause, add an image instead.
### CORRECT, THEN CHECK
`match_to_anchor()` runs before the palette gate, so the gate is a safety net for what arithmetic
could not fix rather than a tripwire for what it can. It pairs each of a frame's dominant colours
with its nearest anchor colour, fits one gain+offset per channel, and applies it to every pixel.
Pairs further apart than `NEW_COLOUR` are dropped from the FIT — those are colours the anchor
genuinely has not got, i.e. blood on a fresh wound — but they still RECEIVE the transform, so a
wound stays the right red relative to the body it is on.
### CALIBRATE BEFORE YOU GATE
The discipline that was missing all day. Every threshold in `_regen.py` was picked by measuring
frames known to be right against drifts that were visibly wrong, and rejecting the formulations that
overlapped. Three formulations were tried for the palette gate; two overlapped and were thrown away.
Do not add a gate without printing that table first.
The gates, and the numbers they were calibrated on:
- **stride** — is a walk cycle ONE DRAWING WITH THE LEGS MOVED? Height ratio, upper-body delta, leg
delta on a feet-aligned overlay. Good pairs: brute lurch `1.00/0.000/0.638`, shambler
`1.00/0.070/0.520`. Failures sat at upper 0.47-0.85 (two unrelated illustrations) or legs 0.09
(the same picture twice). Opt-in per batch, because a four-frame RUN legitimately swings the arms.
- **palette** — are the big colour areas the anchor's colours? TOP SIX by area, proportions ignored
so a pose change is invisible, and blood is never in the top six so a fresh wound cannot look like
drift. Right frames 0.0-8.5, visible drifts 12.0-15.9, threshold 10. **This one number replaced
four hand-picked features** (`kit`'s shirt redness, shorts brightness, shorts hue, near-white
fraction), every one of which a different character broke — the brute's shirt has no red in it at
all, two of the four move with POSE, and none of them ever measured SKIN.
- **changed** — did the wound actually happen? A copy scores 0.02-0.05, an amputation 0.12-0.38, a
decapitation 0.27-0.75. Measured whole-body OR above-the-hip, whichever moved more, because an arm
is a body-relative amount of silhouette: 12-38% of a lean man, 9% of a brute.
- **no grids** — asked of each cut PIECE, not of the sheet. A body is vertically continuous, so a
full-width empty band inside one piece means the cut caught two of them.
- plus the older ones: frame count, magenta background, nothing touching a canvas edge, inner-edge
density.
### Traps, each of which shipped something silently before it was caught
- **A rescue that turns a loud failure into a quiet one is worse than no rescue.** `_fine.py` gained
a valley-cut fallback so a sheet whose figures touch is not thrown away — and it happily sliced a
2x2 GRID into four frames of a torso above someone else's boots. It installed cleanly and read in
the log exactly like a success.
- **A failed stage must block the stages drawn from it.** The brute's headless sheet installed off an
arm-off stage that never landed: three perfect frames of a two-armed corpse. Running was worse than
failing, because it SUCCEEDED. `_regen.py` now skips a batch whose `ref_only` is in a failed
batch's outputs.
- **A placeholder is a stand-in for art that does not exist yet, and nothing else.** Re-running
`--variant` to rewrite a spec re-seeded EVERY placeholder from the master and silently replaced a
finished brute's serve and fall sheets with copies of the lean man they were posed from. Three
passes were then spent regenerating sheets that already existed. `variant()` never overwrites now.
- **The detail bar RATCHETS against the file being replaced**, so a chained stage measured against a
copy of the master has to out-detail art two steps better than the stage it follows. Chained
placeholders are seeded at RUN time, from whatever the reference has become by then.
- **`_fine.py` keyed every magenta pixel, not just the background** — and the generator paints racket
strings in the backdrop colour more often than not, so sheet after sheet installed a body holding
an empty hoop. Background is the region connected to the border; magenta left INSIDE a body is
repainted pale grey. (`Image.fromarray` images are READ-ONLY and PIL's `floodfill` swallows the
write error, so the first version of that fix was a silent no-op.)
- **Put the background rule FIRST in the prompt.** Six rejections in eleven rolls were the generator
painting the zombies on a tennis court, because the magenta instruction sat behind three hundred
words of "keep everything exactly as the reference".
### Still open
- **`changed` does not know WHAT changed.** It passed a "headless" brute that still had its head,
because something moved. It measures that a frame differs from its reference, not that it differs
in the right way, and no cheap measure of "the head is gone" has been found.
- **A variant is reliable for its play sheet and a gamble for its wound stages.** The brute's healthy
eleven are good; its arm-off fall frames are standing figures of the wrong build and its headless
row is not headless. `--variant <key>` and `_master.py`'s master sheets are the right shape; the
wound half needs the reference discipline applied harder than prose currently applies it.
- The brute is PARKED, not deleted: `config.ZFAMS` is one entry away from making it live, its 31
frames are on disk, and its nine batches are in `_regen_batches.json`.
**SUPERSEDED 2026-08-12 — see the next section.** Those 31 frames were not art: `--verify` found
27 of them dead. The brute is now live (`ZFAMS` has `'br'`, the KEEPER wears it) off ONE sheet.
## THE SHELL — CAPACITOR, HAPTICS, PORTRAIT, SWIPE-BACK (2026-08-20)
The first three bullets of LATER item 2 ("What the shell buys GAMEPLAY-wise") are built. **The web
build is unchanged and stays the primary surface** — every line below degrades to a no-op in a
browser, and `splatter-tennis.wuwi.one` keeps deploying off the same repo on every push.
**The zero-dependency rule bends exactly once and exactly here.** `package.json`, `node_modules/` and
`ios/` exist for the SHELL. **No JS from any package ships.** `src/haptics.js` calls the bridge the
shell injects — `Capacitor.nativePromise('Haptics', 'impact', { style })` — never
`import ... from '@capacitor/haptics'`. There is no bundler in this project, so a bare specifier
inside a `<script type="module">` is a hard 404 the moment the same file is served from the web.
`@capacitor/haptics` stays an npm dependency for one reason only: `cap sync` installs its Swift pod.
**Anything added to the shell later has to reach the game the same way.**
> **THE TRAP, and it cost a rebuild to find.** `Capacitor.Plugins.Haptics` reads like the obvious
> zero-dependency answer and it is **silently wrong**. The injected `native-bridge.js` defines
> `Capacitor`, `isNativePlatform` and `nativePromise`, but leaves `Plugins` EMPTY — it is
> `@capacitor/core`'s `registerPlugin`, i.e. exactly the JS we are not shipping, that fills that
> object in. Going through `Plugins` therefore falls straight through to the web branch *inside the
> app*, where `navigator.vibrate` does not exist on iOS — which looks identical to a phone with
> haptics switched off, and would have been diagnosed as "the palette feels flat". Proven the only
> way worth trusting: `simctl launch --console-pty` shows `⚡️ To Native -> Haptics impact` for the
> `nativePromise` form. **That is the check to repeat for the next plugin**; `simctl spawn log
> stream` will NOT show it, because Capacitor `print()`s those lines to stdout.
- **`npm run www`** rsyncs `index.html`, the manifests, `src/` and `assets/` into `www/` with the
same `assets/_*` exclusion `.dockerignore` uses — 32 MB, not the 463 MB the sprite pipeline sits
on. `npm run sync` = tests + that copy + `npx cap sync ios`; `npm run ios` opens Xcode after it.
`www/` and `node_modules/` are gitignored; `ios/` IS committed (Capacitor's own `ios/.gitignore`
keeps the copied payload, Pods and Xcode's per-user junk out of it).
- **Portrait is enforced where it can be**: `Info.plist` lists ONE supported orientation, so the app
never rotates. That formally retires landscape as a surface (OPEN 6) rather than fixing it. The web
half is best-effort — `orientation: portrait` in both manifests and one guarded
`screen.orientation.lock()` in `main.js` — because iOS Safari has no lock at all and the browser
build keeps doing whatever landscape does today.
- **HAPTICS ride the sound map**, as planned: six call sites, all of them places an sfx already fires.
Your racket contact (light, medium on a perfect hit), the serve strike, `killZombie` — the ONE
funnel every weapon routes through — `explode`, `hurtPlayer`, `killPlayer`, and the weapon swap.
**The restraint rule is enforced in `haptics.js`, not at the call sites**: every kind carries a
minimum gap, so a bomb that kills twelve bodies is felt twice (one blast, one kill) and the machine
gun is a metronome rather than one long rumble. `test.js` pins exactly that. No mute switch on
purpose — iOS already routes these through the system's own vibration setting.
**Untested on hardware.** The simulator has no Taptic Engine, so every weight and gap below is a
guess on paper and needs its own phone round before any of it is assumed good.
- **SWIPE LEFT GOES BACK** in the menus. Every screen but the splash wears exactly one chevron, and
that button already knows where back is for its own screen — so the gesture finds the visible
`.chev` and clicks it rather than keeping a second map of the wizard that would drift the first
time a stage is added. Threshold 60 px, horizontal-dominant so the stats and board screens still
scroll. The `touchend` listener is deliberately **not passive**: a swipe that starts and ends
inside one of the big poster cards also produces that card's click, and `preventDefault` is what
stops the screen answering forward in the same frame the chevron walked it back.
**iOS convention is the opposite** (the system pop gesture drags rightward from the left edge).
Built to the brief as asked; flipping it is the sign on one comparison in `main.js`.
- **The app has no server behind it.** Inside the shell the page is served from the bundle
(`capacitor://localhost`), so `fetch('api/scores')` and the netplay socket would have resolved
against the app's own files. `config.NATIVE`/`config.API` and one branch in `net.js` point the
shell at the live host by name; the web build keeps the page-relative form, which is the only
reason localhost, staging and prod all work off one file with no switch. Consequence to remember:
**the app always talks to PROD**, never to staging.
Verified: `node test.js` green (105) across four seeds; the app builds and launches on the iOS 26.5
simulator; `isNativePlatform()` reads true inside it (so the board really does point at prod) and
both bridge calls reach the native plugin; swipe-back walks the whole wizard (mode -> modesub ->
back -> back -> splash) in the browser pane, and vertical drags, short slides and rightward swipes
correctly do nothing.
**Not verified: anything that needs a real device** — the Taptic Engine, 60 fps under WKWebView at
`RES` 4 (OPEN 1, and LATER named this as the second place that risk lands), and WKWebView audio.
## THE FRIEND ACROSS THE NET WEARS YOUR KIT, FROM THE FRONT (2026-08-20)
Oliver, on a netplay match: *"the opponent color is displayed correctly, but the shirt is colored not
the trousers (men) and for women it is not the whole dress"* — and his own diagnosis was the right
one: **we did not have the player character from the front.** The friend was drawn on the ROSTER
opponent's sheets (`o_*`: red polo, dark grey shorts) and dyed on a third hue arc, `TINT.oKit`.
**Red is the one hue a kit cannot own on this art, and no arc could have fixed it.** Both halves
were measured before anything was generated:
- On the men's sheet the only saturated red is the SHIRT — the shorts are neutral grey at
saturation 0.05, so a hue rotation can never reach them. Your own pick dyes your SHORTS. Two
people in one match, wearing their colour on different garments.
- On the women's sheet the red top sits at the same hue as her SKIN and her HAIR (top h≈0 and 336-355,
shaded skin h 13-24, and the arc's own floor is satKit 0.15). Rendering the shipped tint with the
ACID pick put green blotches down her arms, her legs and her ponytail. That was live, and nobody
had looked at it.
**The repaint was tried first and it fails, which is worth knowing.** Mapping the red kit to white
and the grey shorts to blue offline — no API, no new art — gives a genuinely good MAN (white polo,
blue shorts, right shoes) and a ruined WOMAN: the same hue overlap eats her hair, her face and her
leg shading. `_famref`'s `bed_magenta` is also a trap on this art — it repaints the largest PALE
blob, which is the racket bed on the z_r* master but a SHOE here, so `_frontroll.open_bed()` asks
whether the bed is already keyed out before touching a frame.
So the friend got his own sheets: `n_*`, the player's kit drawn from the FRONT, tinted through the
PLAYER's arcs and the tour's own mode — his shorts dye, her whites wash, exactly as yours do.
`TINT.oKit` is deleted. The roster CPU keeps `o_*` and stays as drawn; it is not a person.
**Two rolls made all twenty sprites, and the method is the reason.** `_frontroll.py` is `_famroll`'s
trick applied to tennis: IMAGE 1 is the POSES (the ten installed `o_*` frames composed from disk at
one scale, 4/4/2, gated for band and figure count before a call is made), IMAGE 2 is the CHARACTER
(`p_idle1`, from behind — the model is told outright that it is inventing the face). `_zsheet.cut()`
takes a `want`/`level` pair now so a non-zombie family can reuse the cutter; `level` names the frame
groups that must come back the same height (the idle pair, the bounce pair, the stride pair — the
serve toss and the smash are meant to be taller and are left alone). Heights came back within 1 px
of the `o_*` originals they were composed from.
The one thing the first roll got wrong: it drew a TENNIS BALL into both bouncing frames. The game
draws the ball; a drawn one is a blob stuck to his hand. The prompt says so now.
**THE TOUR RIDES THE WIRE TOO** (Oliver, same session: *"ja, das geschlecht sollte auch über die
leitung gehen"*). A tour is which BODY you play in, so it is part of the look and it goes in the same
message (`u`, alongside `k`/`s`/`n`). Before that, both phones drew the friend in whatever tour THIS
phone picked, so a men's/women's pairing showed each player a body their friend was not using.
`oTour()` in render.js is the one accessor — yours everywhere, theirs in netplay — and it feeds the
three places that must agree: the sprite suffix (`SK`), how the art takes a colour (`PS`: her whites
wash, his blue dyes) and the palm the idle ball bounces under (`BOUNCE_IDLE.oDx/oApex`, measured per
body). An older build sends no tour and falls back to ours, which is exactly what it used to do.
Those palm knobs were re-checked against the new art rather than assumed: the ball marker still
lands under the drawn palm on `n_bounce` and `n_bounce_f` at the values measured off `o_bounce`,
because the poses were copied. No retune.
**And the SETUP SCREEN uses the front too** (Oliver, same session: *"das heisst aber auch, dass wir
in der farb- und charakterauswahl den frontfacing nehmen können"*). Picking a face off the back of a
head was the best the art allowed; `drawLook` draws `n_idle1` now — same kit, same palette, same
tint pass, so it still cannot drift from what walks on.
## THE RUN CYCLES CAME OUT OF A VIDEO — the still models cannot draw a gait (2026-08-15)
**A ROW OF POSES IS NOT A WALK CYCLE, and six sessions of prompting could not make it one.** Asked
for eight running figures, the image model draws three phases and repeats them with the same leg
leading. Oliver, watching the sixth such roll: *"the feet look rather like ropeskipping than
running... the legs need to alter in a balanced way"*. Everything was tried against it and lost: a
figure-by-figure LEFT/RIGHT skeleton spec, a closing "walk the row and name the leading knee"
checklist, an explicit last-against-first loop clause, dropping the count from 8 to 6. `_stride.py`
CAN swap the legs of one frame (it drew the male's twin at 81% legs / 12% upper on the first roll),
but a pair is a pair, not a cycle.
**A VIDEO MODEL HAS NO SUCH PROBLEM, because it moves a body instead of drawing poses.** One 5 s
image-to-video clip, ~$0.42 through the OpenRouter key already in `.env` (`POST /api/v1/videos`,
async, and the "unsigned" result URL still wants the Authorization header — without it you save a
67-byte 401). `assets/_vidroll.py` films the clip and cuts it. What the clips taught:
- **PIN THE POSE AT BOTH ENDS** — `first_frame` and `last_frame` set to the same still. Her first
clip, pinned only at the front, had rotated her into a side profile by the halfway mark.
- **MEASURE THE STRIDE.** `--cut` scores every frame against every lag with `_famref`'s
`legs`/`stride_diff` and installs N phases spaced evenly across the lowest-scoring period. Her
clip strides in 6 frames at 16 fps, his in 23 — the same "every fifth frame" sampling that
flattered hers made his read as a stagger.
- **WHOLE FRAMES, THROUGH `_stride.cut`.** Raw clip frames are soft, and the chroma key eats
lavender-white sneakers unless the test is STRONGLY magenta (`r-g > 70 and b-g > 70`). The first
attempt kept only the clip's legs under his own drawn torso; matching hips frame by frame cost an
evening of seams (Oliver: *"the upper body moves as well so the 2 parts seem drifting"* — the
torso has to be placed on each frame's own hips, and even then her skirt defeated it).
- **THE FIRST FRAME IS THE WHOLE JOB.** Generate it as a single still: her identity reference plus
HIS approved run frame as the POSE picture ("she is the person; his pose is the instruction") —
one roll, no retries, open racket bed. A soft first frame gives a clip whose racket wobbles,
which is exactly what his weaker cycle shows.
**Settled with it:** `TUNE.runHz` 2.4 -> **1.9** (2.4 on real phases: *"this looks like
roadrunner"*), the player no longer clamped to two frames — `runN` probes the sheet **through
`SK()`**, so the women's tour counts her six against his six rather than reading his sheet for both
— and the sprite bench grew two PLAYER stops (`beats.js` `BENCH_CHARS`), because a player cycle
that is only ever seen mid-rally is a cycle nobody has watched. And **bump `SPRV`**: the frames were
right on disk and wrong on screen for an hour because `assets/*` is cached for a year unless the
`?v=` moves.
## THE RUN CYCLES — one call, one row, one character (2026-08-13)
The p_run/o_run frames were generated ONE AT A TIME in the pre-pipeline era and it showed: the male
player's four frames measured 91/110/92/98 px wide, frames 1/3 and 2/4 were near-duplicates drawn
differently, and the racket was missing outright. Oliver: *"really awkward... I think we need more
frames to sell this as good running"* — the frame COUNT was never the fault, the drift was.
`assets/_runroll.py` re-rolled all four characters (player m/f, opponent m/f) on the family-pipeline
discipline: identity from a composed reference of SETTLED frames (`_pose_ref_run_<k>.png`, tracked,
no API — the broken run frames stay OUT of it), poses from skeleton language in the prompt, one 4K
21:9 call per character, `_fine.py` cut, stride gate, judged by eye per roll. What it added to the
pipeline's knowledge:
- **The model negotiates the COUNT.** Asked for 8 it returned 7, then 6, then 8 — and the row it
draws at its own count is the coherent one. The cutter accepts 6..8 and `drawChar` probes
run8/run6/run4/run2, so a good 6 beats a fought-for 8. State the count you can SEE with `--n`
when the figures touch (a low racket bridges every blob gap).
- **A run cycle is the one pose set whose generator cliché is RIGHT.** No pose reference needed —
pin only the frame count, the facing, the racket ("a figure with an empty hand is WRONG", early
and loud, after roll 1 dropped the racket from six figures of seven).
- **A back view has no direction.** Roll 1's "seen from behind, sprinting right" came back running
straight away up-court. The working language: back THREE-QUARTER view, body angled toward the
right, "NOT a straight-away back view: he moves ACROSS the frame".
- **The old male opponent art faced LEFT and had been MOONWALKING right since install** (char art
faces RIGHT by convention — the gameplay.js note at the swing aim). The new rows are all drawn
facing right, which quietly fixed it.
## THE TWELVE-RUNG LADDER — four new families, and the pair-reference lesson (2026-08-13)
Oliver: at least 12 opponents. His picks: **DIRECTOR, CHAMPION, PHYSIO, MASCOT** — all four already
existed as chapter bosses, so each family anchors on its own settled boss frame (`z_dir1`,
`z_champ1`, `z_physio1`, `z_mascot1`): the rung and the boss are the same person, which is the
roster's narrative. Rolled through `_famroll.py` (rows added), cut with `_zsheet.py`, HEAD_CROP
measured for the four. The ladder is now 12 rungs (mascot at 5, physio at 8, director 11,
champion 12) and **`matchFoe` is DECOUPLED from `bossArt`'s cycling** — his call, champion caps
rung 12 while chapters keep their 10-rotation. The lists share characters, not arithmetic.
**THE PAIR REFERENCE, and when words lose.** The champion came back club-pro-slim through FOUR
rolls — two prompt escalations ("shoulders TWICE as wide", "if he is as slim as image 1 it is
WRONG") and a general build-override clause in `_famroll.prompt()` all lost to the pose sheet's
figures. `char_ref` now takes a `pair`: the character pasted NEXT TO a club-pro frame on one
ground line, so the size difference is shown, not described. Even that only half-worked, so the
champion's mass is STATURE instead: `OPPONENTS` rows carry an optional `h` (world height, read by
`zombieH` so the serve aims at the body actually drawn) — champion 2.1 m, the Bulk 2.0 m. When a
build matters, budget rolls for it, and remember the fallback is a data row, not a fifth roll.
## THE ZOMBIE FAMILY PIPELINE — one sheet, one character, 31 sprites (2026-08-12)
**This replaces the nine-batch flow above for a whole opponent family.** `_regen.py` still exists and
is still right for single sheets, remasters and the boss roster; what it could not do is make a
FAMILY that is one character, because nine separate rolls is nine chances to drift and it took every
one of them — three different brutes, and a corpse that came back as a living man.
### The rule the whole thing rests on
**ASK FOR THE WHOLE FAMILY IN ONE IMAGE.** One roll, one light, one palette, 31 figures. Consistency
stops being something you police with gates and becomes a property of the request. This is also what
the model's own guidance says: frames generated in bulk as a sheet hold their character; frames
generated one at a time do not.
### THE POSE REFERENCE IS `assets/_pose_ref_family.png`, AND IT IS TRACKED
**One file is the reference for every zombie tennis character there will ever be.** 31 frames of the
club pro in 9 row bands, exactly `_zsheet.layout()`'s order, on magenta. Every family in `ZFAMS`
except `r` itself was drawn off it. It lived in `assets/_refcheck/` — which is **gitignored** — so the
thing the entire pipeline rests on was untracked and unreproducible for as long as it existed. It is
`assets/_pose_ref_family.png` now, next to `_pose_ref_serve.png`, and it is committed. It ships in the
image via `COPY assets`, like every other source sheet; that is not worth a Dockerfile exception.
**Nothing in this repo composed it.** `_regen.ref_sheet()` builds ONE row at ONE scale — it is not
what made a 9-row sheet. Whatever did was ad-hoc and is gone. Rebuilding the sheet therefore means
writing the composer INTO the repo, once, with the gate below.
#### NEVER PUT THE REFERENCE THROUGH THE MODEL
The version of this sheet that produced the six broken families was a GENERATED RE-DRAW of `z_r*` —
nine separate 4K calls, one per row band, stacked locally. That round trip is where both of Oliver's
sprite-bench faults came from, and `_famref.py` exists to make it impossible: the sheet is composed
from the sprites on disk, one scale, NEAREST, no API. See "The three defects" below for the numbers.
### The four steps
```bash
# 1. THE POSE SHEET — composed from the 31 z_r* frames, one scale, NEAREST, gated. No API call.
uv run --with pillow --with numpy python assets/_famref.py
# 2+3. THE CHARACTER (one frame at 4x NEAREST on magenta) and ONE call: poses from image 1,
# character from image 2. Both live in _famroll.py; add a family with one row in its FAMS table.
uv run --with pillow --with requests python assets/_famroll.py <famkey>
# 4. CUT IT BACK DOWN — review first, install second.
uv run --with pillow --with numpy python assets/_zsheet.py assets/_refcheck/fam_<k>.png <k> --out assets/_refcheck/rev_<k>
uv run --with pillow --with numpy python assets/_zsheet.py assets/_refcheck/fam_<k>.png <k> --install
```
`uv run --with …` is how these run: this machine's python has no PIL or numpy of its own, and the
previous session's interpreter was a uv ephemeral env. `SPLAT_PY` still overrides for `_regen.py`.
### Only ink votes: how seven fine families shipped pixelated (2026-08-14)
`_fine.block_size()` finds the generator's art-pixel grid by scoring b×b blocks for uniformity — and
it scored ALL of them. `key()` zeroes the background, a zeroed block is uniform at every b, and on a
9-row family sheet the background is ~85% of the figure bounding box, so the background ALONE cleared
the 0.82 bar at any block size tried. Measured on `fam_ru.png`: 0.0% of ink blocks uniform at b=11,
99.2% of empty ones, overall 0.821 — detected block 11, and `_zsheet` then honestly snapped 428 px
figures down to 39 art px and scaled them back up. The pixelation was manufactured in the cut; every
sheet the model returned was fine. br survived at 0.775 only because its ink coverage runs four
points higher, and the whole per-family chunkiness ranking (br fine … ch/ru/sh/st at 6 px blocks) was
ink-coverage noise around one threshold. Background-only blocks get no vote now, and all eleven
families were re-cut from the sheets already in `_refcheck/` — no new rolls, no tokens.
Two rules fall out of it:
- **A detector proven in one geometry is unproven in another.** `block_size` was validated on
single-row `_regen` sheets where figures fill the bounding box; the 9-row family sheet silently
inverted its ink/background ratio. The threshold never changed — the data under it did.
- **Diff the artifact against its source before blaming the source.** The chunky sprite and the fine
sheet it was cut from sat side by side on disk for two days. One `Read` of each would have located
the bug immediately; instead the first two theories (legacy `_regen` prompts, model roll variance)
both blamed generation. Oliver's screenshot pair settled it in one glance.
**A frozen stride pair in the master blocks the whole pipeline, by design.** `_famref.py` refuses to
write the sheet if any of the three stride pairs has legs that barely move — because the model copies
what it is shown, so a frozen pair in the reference is a frozen pair in every family drawn off it.
Draw the missing twin first:
```bash
uv run --with pillow --with numpy --with requests python assets/_stride.py z_rw1 # then z_rw2
uv run --with pillow --with numpy --with requests python assets/_stride.py z_rw2 --example z_rw1
```
Then: add the key to `config.ZFAMS`, put `fam: '<key>'` on an `OPPONENTS` row, bump `SPRV` and
`main.js?v=`, `node test.js`, and look at it in `#bench` (arrow keys walk families and wound stages).
### The API facts, measured, not read off a blog
Queried from `/api/v1/images/models` — the endpoint's own capability descriptor:
| | |
|---|---|
| `resolution` | `1K` / `2K` / `4K`, **default 1K**, uppercase K required |
| `aspect_ratio` | a 10-value enum (1:1 … 21:9). No arbitrary ratios — a sheet gets SNAPPED |
| `n` | **min 1, max 1** |
| `input_references` | 0…14 |
- **Use `/api/v1/images`, not `/chat/completions`.** Chat completions has no resolution control, so
every call silently returns 1K. The same sheet came back 1616x656 through chat and **6336x2688**
at `"resolution": "4K"` — 15x the pixels, for one JSON field.
- **`n` is 1. Always.** "Return both halves" is not a thing the model can do; asked for two, it
merges them into one picture and returns that same image twice. Two byte-identical files is the
tell.
- **Pad the input to the aspect you ask for.** The ratio is an enum, so a 1:3.2 sheet handed to a
9:16 request gets rearranged to fit. Pad with magenta and the figures stay where they are.
- **Multiple references compose ONE output.** They are not a batch. Google's sub-limits: <=10 object
images, <=5 character images, <=3 style references.
### What the model will and will not do
- **It cannot output alpha.** No Google image model can. "Transparent background" returns white,
black, or a *painted* checkerboard. The chroma key is not a workaround, it is the method — and
green is the documented default, which is wrong here because our zombies are green. Magenta stays.
- **Its magenta is NOISY**: 8,000–22,000 distinct tones per sheet, corners anywhere from (250,1,250)
to (254,3,253). Key a RANGE. An exact `#FF00FF` match leaves a magenta fringe on every figure.
- **It draws pixel-art-STYLE illustration, not pixel art** — uneven pixel sizes, too many colours,
and it adds anti-aliasing and gradients unless forbidden. Prohibitions help and do not solve it.
`_fine.py`'s block-snap + box-filter + palette quantise is what makes it read as sprite art; the
softness does NOT wash out in the downscale alone (measured against `z_g1`, it stays visibly
smoother).
- **Name each image's JOB in the prompt.** "IMAGE 1 is the layout and poses only; IMAGE 2 is the
ONLY source for who the character is." Without that the majority wins: `racket_play`'s reference
is one zombie and three living humans, and a re-roll came back as a **living man** despite the
prompt carrying the CORPSE rule verbatim. A picture beats a paragraph — including when you did not
mean it to.
- **Frame it as an EDIT, not a composition**, and tell it to match image 1's *drawing style*. Every
"draw a new sheet" roll came back painterly; the edits kept the technique.
### `_zsheet.py`, and the two things that must be GLOBAL
It reuses `_fine.py`'s keyer, art-block detection and column cutter, adds row-band splitting, and
fixes the two things that break a family if they are done per-row:
- **SCALE** — taken once, from the play row's standing frame. `_fine.py` anchors one scale per sheet,
so nine bands would each normalise to their own tallest figure and the serve strike would come out
the same height as the idle stance. The game sizes every sprite off its own PNG height, so that is
a body that pops. Measured after: serve **319**, prone fall **83** — the racket family's own are
307 and 85, arrived at independently.
- **PALETTE** — taken once, from the largest figure anywhere, so a wound row cannot quantise onto a
slightly different set of colours than the row above it.
**And it INVERTS `_fine.py`'s enclosed-magenta rule, deliberately.** `_fine.py` repaints magenta
trapped inside a figure as pale grey, because the generator used to fill racket string beds with the
backdrop colour BY ACCIDENT. Here the prompt asks for exactly that and gets it, so the bed cuts to
transparency and the racket is an open hoop. That is only safe because nothing else on the figure is
magenta — check a new sheet.
### Three fixes this found in `_regen.py`, which still apply to it
- **A margin is a PROPORTION, never a pixel count.** "At least 150 pixels of empty magenta at every
edge" means nothing to a model that picks its own output size; it composed at 92% of a canvas it
chose and clipped 3 rolls in 4. Stated as a fraction, edge rejections went to 1 in 3.
- **A reference sheet must not show a racket as a magenta hole.** Older sprites were cut before
`_fine.py` learned to keep enclosed background, so their string beds are transparent — `o_smash`
has 3,425 such pixels — and pasted on the magenta canvas they came back as magenta. Four of the
racket family's nine references were showing the generator exactly what the prompt forbade.
`ref_sheet` fills them with a woven pale bed now.
- **`--verify` asks the question no gate here ever asked**: is the art already on disk what it claims
to be? Every other check fires on a NEW roll and says nothing about installed frames — and a
chained batch draws its reference from installed frames. It found 27 of the brute's 31 dead:
`z_brw1fall` was ONE DRAWING PASTED FOUR TIMES (0.011–0.055 apart, where a real fall sequence is
0.53–0.89). Run it before trusting any chained batch.
### The three defects the sprite bench found, and the fix (2026-08-12, ALL FIXED)
Oliver walked the sprite bench and reported the same two faults on every family: the healthy walk
loses its head, and the wounded walk's feet do not move. All of it traced to `_pose_ref_family.png`,
and the pipeline had copied it faithfully — **which is the pipeline working.** Measured per figure as
the implied scale of the sheet against the `z_r*` PNG it was pasted from, width and height separately:
```
band 0 play 1 w2.339 h2.362 | 2 w2.341 h1.996 <-- the ONLY non-proportional figure in all 31
3 w2.345 h2.360 | 4 w2.333 h2.361
band 1 serve 2.335 | band 2 fall 1.902 | band 3 w1 walk 2.34 | band 4 w1 serve 2.30
band 5 w1 fall 1.922 | band 6 w2 walk 2.38 | band 7 w2 serve 2.047 | band 8 w2 fall 1.945
```
**1. `z_r2` IS PASTED WITH ITS HEAD CLIPPED OFF.** Width scales at 2.341, height at 1.996 — 33 sprite
px missing off the top, width untouched. Every other figure is proportional to three decimals, so
this is a clip and it is the only one in the sheet. `z_r2.png` on disk is fine (123x225, head
present): the damage is in the composition, not the sprite. One bad paste, six families — and this is
the whole of "healthy walk loses its head", because drawZombie picks frames 2/3 as the walk pair.
The last commit's walk-frame levelling makes it WORSE, and that is not the levelling being wrong: it
reads the missing head as a height error and scales the body up to compensate, so `z_sh2` is 147 px
wide against `z_sh3`'s 116. The walk flickers between a normal body and an oversized headless one.
**2. THE SHEET IS NOT ONE SCALE, IT IS FIVE.** The fall bands sit at 81% of the standing bands and the
w2 serve band at 87%. That is the exact bug `_zsheet.py` was written to prevent on the way DOWN,
present on the way UP — the sheet tells the model a falling body is a fifth smaller than a standing
one. **`_zsheet.py`'s fall1 re-anchor and the walk levelling exist only to undo this.** Rebuild the
sheet at one global scale and both should become no-ops; that is the check that they were
compensations and not fudges.
**3. THE WOUNDED STRIDE PAIR WAS NEVER DRAWN, and a rebuild will not fix it.** Leg-region silhouette
difference within the stride pair (feet-anchored, mass-centred, bottom third):
| | healthy 2 <-> 3 | w1 <-> w1b | w2 <-> w2b |
|---|---|---|---|
| the reference sheet | **51.1%** | 14.3% | 14.5% |
| installed `r` | 51.0% | 15.2% | 14.8% |
| the six new families | 41–70% | 4.8–19.7% | 6.2–19.9% |
`z_rw1b` genuinely has the same legs as `z_rw1` on disk, so the club pro has been sliding through both
wound stages since the day he shipped — `r` IS the reference, which is why its numbers match it
exactly. drawZombie is animating correctly (verified by instrumenting `drawImage` on the bench: it
alternates `z_rw1` <-> `z_rw1b` every 0.42 m of ground covered, the right frames at the right
cadence). This one needs the master's `w1b`/`w2b` REDRAWN with the legs swapped before a rebuild is
worth spending a roll on.
**THE GATE THAT WAS MISSING IS ON THE INPUT.** `_zsheet.py` checks the roll that comes back — band
count, figure count, stacked bodies, edges, palette, heights — and nothing had ever checked the sheet
handed TO the model. `_famref.check()` is that gate: it re-reads the sheet it just wrote with
`_zsheet`'s own band and figure detection, and refuses one where any figure's width and height scales
disagree by more than 2% (the head clip was a 0.35 discrepancy) or where a stride pair is frozen.
#### How each one was fixed
| defect | fix | cost |
|---|---|---|
| 1 · head | `_famref.py` composes the sheet from the sprites on disk, one scale, NEAREST | no API call |
| 2 · five scales | same — one `k` for all 31 figures, derived once | no API call |
| 3 · frozen wounded stride | `_stride.py` draws the missing twin, shown the healthy pair as the example | 2 rolls |
Defect 3 is the one that needed new art, and the thing that made it work after every previous attempt
failed was **showing the edit instead of describing it**: IMAGE 2 is the healthy stride pair side by
side, the same character performing exactly the change being asked for. Words alone ("identical above
the hips, legs clearly swapped") had returned near-identical drawings for as long as they were tried.
The headless twin then took the ARM-OFF pair as its example rather than the healthy one — a wounded
body is a closer picture of the edit, the same chaining the wound ladder itself uses.
`_stride.py`'s gate measures three things and prints all three: legs moved (>=30%), body above the
hips did NOT (<=25%), and the racket bed survived (>=0.6x the base's area). **Its thresholds are
calibrated on stride pairs that already ship, not chosen** — the table is in the file. My first
guesses, 35 and 14, rejected `z_e1/z_e2` and `z_g1/z_g2`, both of which are accepted art.
**And a stride twin FILLS the racket bed instead of keying it out** — the opposite of `_zsheet.py`,
for the opposite reason. `_zsheet` cuts a whole family, so every bed keys out together and the racket
is an open hoop throughout. A twin is half of a pair whose other half is already on disk with a
painted bed: the first rolls came back with 959 px of enclosed transparency against their base's 0,
which would have flickered the racket face empty/filled once per step.
#### After the fix, measured on the installed sprites
Every family re-rolled off the rebuilt reference. Leg difference within each stride pair:
| fam | walk 2/3 | w1/w1b | w2/w2b | | fam | walk 2/3 | w1/w1b | w2/w2b |
|---|---|---|---|---|---|---|---|---|
| r | 51.1% | 68.1% | 36.7% | | ru | 55.9% | 59.1% | 38.7% |
| br | 40.7% | 42.0% | 26.8% | | gk | 41.1% | 44.6% | 30.6% |
| sh | 59.4% | 71.4% | 43.9% | | lj | 38.4% | 45.5% | 27.4% |
| bb | 56.5% | 67.1% | 43.2% | | st | 51.6% | 66.1% | 35.7% |
| | | | | | | *was 4.8–19.7%* | | |
Every play row has its head, verified frame by frame in `#bench`, and all 24 family/wound-stage states
alternate a real pair (checked by instrumenting `drawImage` on the bench, not by eye). `br` and `lj`
sit at 26.8 / 27.4 on the headless pair — the hardest one, subtler than the rest but visibly moving;
worth a re-roll if they ever read as stiff. `lj` needed one re-roll: its first came back frozen on all
three pairs with the headless row 31 px short.
### Still open
- The style is close but not identical to the hand-made sprites: softer outline, blended shading
rather than dithering. Oliver accepted it ("I like the style") on the grounds that a RELIABLE way
to generate art is worth more right now than an exact match.
- The 9:16 padding spends most of the 4K budget on empty magenta — the figures came back at 243x288
before downscaling. A layout that fills the canvas would give each figure far more real pixels.
## 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 "<the changes>" --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 <noreply@anthropic.com>`.
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 : <default>`) 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.
**RETIRED IN THE APP, 2026-08-20** — `Info.plist` lists one orientation, so the shell never rotates
and landscape is officially not-a-surface there. The WEB build can still be turned sideways (iOS
Safari has no orientation lock), so this stays open, at a lower stake: it is now a browser-only
cosmetic gap rather than something a store reviewer will see.
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, extended 2026-08-06, model revised 2026-08-18.
**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 (revised 2026-08-18): FREE download, one €2.99 non-consumable IAP that unlocks
everything, no ads.** Both stores. This supersedes the original "paid $2.99 upfront" decision
(2026-07-31) — the whole funnel is short-form video → impulse tap → store page, and an impulse does
not survive a price tag on an unknown game; the free tier does the convincing, not the price. €2.99
stays the number (there is no 2,00 € tier; revenue is noise either way per the expectations below).
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. A StoreKit
unlock does not break "Data Not Collected", but it does require a **Restore Purchases button**
(no accounts — StoreKit restore IS the reinstall/new-phone path; Apple rejects unlock IAPs without
it), and it is the wrapper's first real IAP plumbing.
**The free tier (Oliver, 2026-08-18):**
- **Gore: free through the FIRST CHAPTER BOSS BEATEN**, cut on the chapter-2 card — the same cut,
same build flag as the web demo (item 1). The free tier must contain everything a promo clip
promises (transition, horde, weapons, duel, boss) and must end on a WIN, not a wall.
- **Zombie tennis: first 7 opponents, no tournament.** Frame the wall diegetically when the
tournament frame exists: QUALIFYING is free, "YOU'VE QUALIFIED — the main draw is in the unlock" —
the cut lands on a win in tennis language. Check where the six new families sit on the 18-rung
ladder; if rungs 1–7 are mostly the base family the free tier undersells the roster.
- **Classic tennis: fully free.** It is the decoy for the transition and costs nothing.
- **Multiplayer: joining free, hosting paid.** Friend-code rooms make every paid copy recruit its
own opponents — a payer hosting for a free friend is a live demo. (The socket is a staging
prototype; this gate waits on LATER item 7 shipping.)
- **World board: posting requires the unlock.** Free players SEE the board — put the upsell where
their row would have been, envy beats a lock icon. With no accounts this is also the cheapest
anti-spam gate the board can have. Mind the cold start: a payers-only board is nearly empty at
the honest sales estimate; the testteam's rows partially cover it.
- **Upsell placement is a feel rule**: only on win moments (chapter card, felled card) — never in
the death→restart gap that `EATEN_RESTART` protects.
- **Gates do not lower the age rating** — the rating questionnaires ask about the binary's maximum
content, gated or not, so gore in the free tier rates the free download (expect Apple 16+, USK/
PEGI 16) regardless. The gates buy conversion, not reach.
**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.
**Timing (Oliver, 2026-08-18): the web build stays UNCAPPED for now** — the testteam runs the
PWA. At store launch the public web build takes the **same gates as the app's free tier** (one
flag), or the website is strictly better than the free app and defeats every gate above.
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): retired for the store build by the portrait lock below (Oliver,
2026-08-18) — locked portrait makes landscape officially not-a-surface, which is cheaper than
fixing it for review; the web build keeps whatever landscape does today.
**What the shell buys GAMEPLAY-wise (2026-08-18)** — the countable list of plugins/config the
zero-dep rule bends for; only haptics touches game code:
- **Portrait lock** — one config line; see above.
- **HAPTICS — the real win**, flatly impossible in iOS Safari. Palette v1 is the stock iOS
primitives (three impact weights, three notification patterns, the selection tick) mapped
onto the EXISTING sound-effect call sites — the SFX taxonomy already decided what deserves
punctuation, so the audio map IS the haptic map. One `haptic(kind)` helper, no-op on the web.
Restraint rule: contact ticks are the metronome, kills punctuate, heavy is rare — a game
that buzzes constantly numbs the hand in minutes. Core Haptics custom curves only if the
stock palette feels flat. Device-only (the simulator has no Taptic Engine); needs its own
phone playtest round before any of it is assumed good.
- **Edge-gesture deferral** — the shell can require two swipes for the home indicator /
app switcher, so a bottom-edge flick (exactly where the one-handed thumb lives) stops
yanking the player out of the game. The web has no say in this.
- **Autoplay allowed in WKWebView** — the umpire speaks on first load, no gesture unlock
(kills LATER item 6's mute-on-first-load risk *inside the app*; the in-app-browser web case
remains). The iPhone mute switch becomes a choice — games usually play through it.
- **Real app storage** — stats, run history and the €2.99 unlock state survive; Safari/PWA
storage can be evicted under pressure. A tester's career may vanish; a customer's must not.
- **Assets in the binary** — instant cold start, no `?v=N` dance in the app. Flip side: app
updates take store review (days), so iteration stays on the web; the app ships snapshots.
- **Skipped on purpose**: Game Center / Play Games (a second leaderboard dilutes the diegetic
world board) and push-notification retention nags (same instinct that rejected
interstitials). Both addable later without regret.
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.
**THE CAPTURE RIG IS BUILT (2026-08-18), AND THE SHOT LIST IS DATA.** `python3 tools/capture.py`
fills `promo/clips/`; `python3 tools/capture.py horde boss` shoots two; `python3 tools/capture.py
my-shots.json` shoots another list entirely; `--seed 77` re-rolls every take. What to shoot lives
in `tools/scenes.json` — eleven scenes today — and its `"_readme"` block IS the schema, written so
an agent can open that one file and write a new batch: every key with its range and default, all
eight tins and what each does, the five beats, the crowd roster `diversity` walks, the ten bosses
by chapter, and the traps (a serve tin is spent in ~4 s of autopilot fire; `movement` fights the
match's own auto-run legs, so a zombie-tennis scene leaves it 0).
`tools/clips.js` is the engine that knows how to shoot any of it: `tools/capture.py` serves the
repo with it injected, opens a throwaway-profile Chrome, and the page stages the scene, records
canvas + audio, POSTs the webm back; ffmpeg writes h264/aac. Nothing is faked for the camera —
every clip enters through the doors a player uses (`T.start`, `T.intoSplatter`, `startHorde`,
`startBossFight`, `startMatch`), the crowd walks in where the game's own spawner puts it, and the
player walks on held arrow keys through the real `axisX`. The two pilots are deliberately
mediocre (the tennis one IS the reachability soak's from `test.js`) and are chosen by what the
court is doing, not by config: `G.mode === 'tennis'` taps to swing, splatter draws the slingshot.
Six things it learned the hard way, none of them obvious:
- **The clip is the canvas BACKING STORE, not the window.** Chrome opened maximised and ignored
`--window-size`, and `resizeTo` did nothing; the frame is pinned instead by giving the canvas a
phone-sized CSS box, which `render.js` measures (`cv.clientWidth`) through its ResizeObserver.
- **THE SCREEN IS A PHONE, not 9:16** (Oliver, 2026-08-18): 393x852 like a 15-class iPhone, which
records at 1080x2340. The game genuinely lays out differently for it — the portrait branch
derives its logical height from the box — so a 9:16 take is not the framing a player sees.
`screen: "9x16"` is still there for a platform that insists.
- **`TUNE.lowPower` defaults ON and caps `RES` at 3**, so the first recordings came out 810 wide
and needed a blurry 1.33x upscale. The rig sets it to 0; with `--force-device-scale-factor=3`
the backing store IS the output and no filter ever resamples a pixel.
- **Audio has no master bus** — every voice in `audio.js` connects straight to `ac.destination` —
so the rig makes `destination` itself a gain node fanning to the speakers AND to a
`MediaStreamDestination`, patched on `BaseAudioContext.prototype` (not `AudioContext`'s) before
the game module boots. No refactor of the game for a tool that ships to nobody.
- **The court is remembered, not passed**: `main.js` reads `localStorage.court` once at module
init, so a scene's court rides the URL and is stored before the module boots.
- **One page load per scene.** A fresh boot is the only teardown guaranteed correct, and it costs
a second: the page reloads itself into the next scene and POSTs `/done` when the list is empty.
`tools/` is in neither the Dockerfile COPY nor the image, and `promo/clips/` is git- and
docker-ignored: the footage is a local build product, not a deployed asset.
Still hand-work after the rig: the name mark on screen (item 5 wants it in EVERY clip), any
cutting, and the caption. Skipped deliberately — a burned-in watermark is a taste decision and
ffmpeg `drawtext` needs a font file the repo does not have in a `drawtext`-friendly format.
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.
7. **MULTIPLAYER: 1v1 zombie tennis (decided worth pursuing, 2026-08-12). THE LIVE-SOCKET
PROTOTYPE IS BUILT (2026-08-14, staging — see the session log) — Oliver chose the socket over
the ghost-match step one. Still open from this item: the ghost match as a solo mode, the
wound-decay hook below, reconnect-and-resume.** Tennis is one of the
friendliest genres in existence for netcode, and this codebase is unusually ready for it — record
the decision and the architecture so neither is re-derived:
- **The sync unit is THE SHOT, not world state.** One ball, one legal hitter at a time
(`ball.lastHit` already enforces alternation), ~one interaction per second, and each flight's
~1 s swallows 100-150 ms of latency before the opponent needs the data. Each client is
authoritative for its OWN contacts (your swing window is judged on your phone — lag never eats
your tap); a dumb relay forwards shot events (~9 numbers: launch pos/vel/gravity/quality) and
point results. No rollback, no shared physics contention.
- **What already exists toward it**: the sim is deterministic-driven (the suite plays the real
modules headless on seeded randomness), `update()` steps fixed dt, ball state is one small
object, `beats.js` is where `startNetMatch()` would live under the court-ownership contract,
and the whole serve/swing/volley system tuned for the zombie match IS the netplay input model.
- **Zero-dep server**: python stdlib has no WebSocket, but RFC6455 handshake + framing is ~120
lines of stdlib and `server.py` is already the one process holding state. Rooms = a 4-letter
friend code in a dict. No accounts, no matchmaking service.
- **The honest cost is the last 20%**: phone radios (screens lock, apps background, networks
switch — reconnect-and-resume is the bulk of the real work), cross-continent mush, and
hitter-authoritative trust (a plausibility check server-side is enough for friend codes; do
not build ranked-stranger anti-cheat). Prototype = a few focused sessions; robust = weeks of
paper cuts.
- **STEP ONE IS THE GHOST MATCH, not the socket.** Record a run's shot timings + placements
(tiny data), serve it over plain HTTP from the existing server, play against the recording —
racing-game ghost logic. Zero realtime infrastructure, feels close to a duel, and half the
netcode (shot serialization, replay determinism) falls out of it as a byproduct.
- **The hook that makes it ours**: score-is-the-body applied to BOTH players — every point you
lose, YOUR sprite decays a wound stage, so the loser of a long match is a shambling one-armed
wreck by match point. Losing IS zombification. Nobody else's tennis game does that.
**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 free+IAP because it feeds
the funnel and the gates protect 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.