Files
splatter-tennis/PROJECT.md
T
wuwiandClaude Opus 5 1b7fc2cdcf Both players run from a video, and the middle chip finally plays you
Six sessions of asking an image model for a row of running figures, and it
never once drew a gait: three phases repeated with the same leg leading,
"ropeskipping, not running". A frame-by-frame LEFT/RIGHT skeleton spec, a
closing checklist, a loop clause, six figures instead of eight — all lost.
One 5-second image-to-video clip has no such problem, because it moves a
body instead of drawing poses. ~$0.42 a character through the key already in
.env, pose pinned at BOTH ends (pinned only at the front, she rotated into
profile halfway), stride period MEASURED off the frames rather than sampled
by eye — hers repeats in 6 clip frames, his in 23 — and the phases cut and
palette-snapped through _stride.cut. assets/_vidroll.py films and cuts it;
her first frame came from one still roll that took his approved pose as a
picture instead of a paragraph.

Wired: runN probes the sheet through SK() so the women's tour counts HER
frames, the player is no longer clamped to a two-frame pair, runHz 2.4 ->
1.9 ("this looks like roadrunner"), and the bench grew two PLAYER stops —
a cycle only ever seen mid-rally is a cycle nobody has watched.

Also in: the difficulty retune (medium is a real step above the shipped CPU
and is where `smart` first appears; hard stepped back off superhuman; easy
closed its gap) with MEDIUM's dial-for-dial pin to TUNE removed from the
test, and Oliver's own transparency fixes on the male frames.

SPRV 138 — the frames were right on disk and wrong on screen for an hour
because assets are cached for a year unless the ?v= moves. main.js v269.

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

279 KiB
Raw Blame History

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-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.rounded, 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.

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 fetches 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 everyone still in the back half, 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.
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:

    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).
  1. 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.

  2. 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.

  3. 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 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

# 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:

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:
    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:

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.
  7. drawCard (the chapter/set cards) still cuts in with no entrance, while modals swoosh (TUNE.modalIn). Offered and not yet requested.
  8. A chapter can be skipped. G.bossDue takes the max, so going a long stretch without ever clearing the court means you face the current chapter, not a backlog. Deliberate; noted in case it ever reads as a bug.
  9. The runner's four frames were fixed BY HAND and must be regenerated. z_f1..4 are currently hip-registered (padded to one width with the hip on the centre line) and frames 3 and 4 are mirrored copies, because all four were drawn leaning the same way and lifting the same leg. That is exactly the post-processing Oliver ruled out — it shipped before he stated the rule. The batch to write asks for a four-frame front-on sprint whose stride and lean ALTERNATE, and the numbers to check afterwards are the per-frame hip centroid (it was 13 px of lateral jump at 12 frames a second) and which leg leads.
  10. Step 6 stays PARKED by Oliver's decision: the crowd turning into zombies (spawning from the stands — attacks from behind, high spectacle, x_crowd sheets already exist) and the ball-boy zombie that catches your shots. Recorded so the ideas are not lost, not as a to-do.
  11. ump_no_deal does not exist. Oliver asked for its removal; ump_no_appeal was removed as the closest match. Worth one confirmation that no other line is still unwanted.
  12. Four sounds are missing and need RESEARCH first (find/choose the source before implementing — every existing sfx is either synthesised in audio.js or a wav in assets/audio/):
    • the ball machine while it moves — a squeaking wheel/castor, so the furniture announces itself. It already telegraphs its shot (machWind 0.9 s); this is the approach cue.
    • burning zombies — fire is an epidemic now (burnSpread, burnTime 3 s), so this is a looping crackle plus something the body does, and it has to layer without turning a lit-up wave into noise.
    • the melee gun — i.e. the machine-gun burst (mgRate 12/s during a horde, mgPickupLen 8 s from a tin). At 12 rounds/s a per-pellet sample will machine-gun the mixer, not just the wave.
    • boss sounds — footfall, wind-up, throw, wound, death. Three designs in BOSS_ART; decide whether they share one set or get one each before recording anything.

LATER — shipping it: demo, stores, campaign

Decided in discussion with Oliver, 2026-07-31 and extended 2026-08-06. None of this is built — the one thing that exists is the domain (item 5). Recorded here so the decisions survive; the ordering below is roughly the order to do it in.

The model: paid, $2.99, no ads. Both stores. No ad SDK, no consent/CMP flow, no ATT prompt, no tracking — which keeps the store card at "Data Not Collected", keeps review trivial, and keeps a zero-dependency project zero-dependency. Maybe later: a free build capped at chapter 1 with an IAP to unlock all chapters. That is a fallback, not the plan — paid→free is a normal move, free→paid is not, so launch paid. Ads are rejected, with one exception held in reserve: rewarded video on the CHALLENGE (below), never an interstitial. An interstitial after death would land exactly in the "one more run" gap that EATEN_RESTART exists to protect, and would end sessions rather than annoy.

  1. The capped web demo. The public web build stops at the end of chapter 1 — after the first chapter boss is beaten (G.runGames >= 10, bossEvery), on the chapter-2 card, ending on a WIN, not a wall: "CHAPTER 2 — continue on the App Store." The cut must sit after everything a promo clip promises (transition, first horde, a duel, weapons, the boss), because the web build's whole job is converting a 15-second clip into "I played it". Implementation is ~10 lines: a check on the chapter-2 card plus a build flag the store build sets to skip it. Build no protection around it — it is client-side JS on a static host, trivially bypassable, and nobody who would pay $2.99 will open devtools. DRM here is pure cost. Consider leaving desktop uncapped (matchMedia('(pointer: coarse)'), already used for autoAssist): nothing to cannibalise there, and an uncapped desktop build is what gets linked.

  2. Store readiness. Wrap the PWA (Capacitor/PWABuilder). Unknowns to check before committing: audio behaviour in WKWebView, safe-area (already solved for the browser, may differ in the shell), and 60 fps canvas under WKWebView at RES 4 — see OPEN 1, this is the second place that risk lands. Costs: a build step in a deliberately build-step-free project, Apple $99/yr, Google $25 once. Landscape (OPEN 6) probably has to stop being under-maintained before a store review.

  3. The CHALLENGE — ship it FREE. Tennis already owns the paid-continue mechanic: Hawk-Eye. On death (G.mode='eaten'), the umpire announces a challenge, the replay shows the shambler's foot over the line, and the call is overturned — you resume. The sport supplies the cap for free (challenges per set), so the limit is diegetic rather than a paywall excuse. Build it as an earned mechanic, not a purchase. It is good design on its own, and it is the only slot in the game that could ever carry a rewarded video if the model changes. Note it does change what a run means — right now the run summary is the whole game — so it needs a playtest on feel before it is assumed good.

  4. Short-form video campaign. The only free channel that reaches people who have never heard of the game; the algorithm serves cold, so every post is a free lottery ticket. Format: vertical (portrait is already primary — no letterboxing, unlike most game footage), open mid-rally on what looks like an ordinary SNES tennis game, reveal the transition at ~3-5 s. Sound matters: the recorded umpire over splatter is what earns an unmute. Volume over polish — 20-30 clips, one export cross-posted to TikTok/Reels/Shorts. No devlog content (it reaches developers, who do not buy games). Clips the game already contains: the transition (the flagship), a boss walking on, the ball boy calmly mopping blood, a flame ball starting an epidemic across a lined-up wave, parrying a boss's volley back into him, a horde, the death + run summary. A capture helper on window.T to jump straight into a given beat (the transition timeline is on G.transT) would make clean takes cheap — not yet built.

  5. How a viewer actually reaches the game — the name does the work, not the link. TikTok and Instagram have no clickable caption links, so the bio link costs two taps (profile, then link) and converts in the single-digit percents. YouTube Shorts descriptions and pinned comments DO click, which makes Shorts the best converter of the same upload. But most people who liked the clip click nothing and search later — so the whole funnel rests on whether the name is memorable and resolves. Three consequences, all decisions rather than work:

    • the name goes on screen in every clip — not a title card at the start (scrolled past), a small persistent corner mark or an end frame after the payoff;
    • the account handle IS the game name (@splattertennis), which bakes the name into the platform's own UI on every video, permanently, for free — this does more than the bio link;
    • pin a comment with the name on every video; "what's this called?" is how interest shows up. The profile page is the real landing page: it should read as a game's page, not a personal one. The domain is registered: splattertennis.com (Oliver, 2026-08-06). It matches the searched name, which splatter-tennis.wuwi.one never could — nobody types that from memory. Point it at the demo build and use it as the single link everywhere. The deploy target itself is unchanged (see the top of this file); this is DNS in front of it, plus whatever store links live on the page.
  6. Test the game inside the in-app browsers BEFORE the campaign runs. A bio-link tap does not open Safari or Chrome — it opens TikTok's or Instagram's embedded webview, so that is the first-run environment for most players the campaign delivers, and it is the least-tested one this project has. Specifically unknown there: audio autoplay policy (the umpire is the hook — if he is silent on first load the clip's promise breaks), safe-area insets, whether PWA install is offered at all (it is not), and canvas perf at RES 4 — the third place that risk lands, after OPEN 1 and item 2 above. matchMedia('(pointer: coarse)') does still work there, so the mobile branches are fine.

  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 paid because it is cheap and protects the design, not because it will earn.

Explicitly NOT to be built (these fight settled decisions, see the invariants above):

  • selling weapons or tins — weapons are only ever won, in the duel; a shop deletes the duel's reason to exist and healPerDuel with it.
  • a meta-progression currency — permadeath and the run summary are the meta layer.

CLOSED — Oliver's fourth playtest (2026-07-30)

The game did not use the full height of his iPhone

Fixed by making the portrait logical height adaptive and letting horizon ride with it — see the invariant above. On a 375x812 pane: logical 270x585 (was 270x480), canvas CSS 375x812, 100% of both axes. RES 3 -> 4 for the "extra pixels" he asked about.

Dying on every game-20 horde

The swish cooldown now scales with crowding — see the invariant. Note the ordering trap recorded there; the first implementation measured the crowd after the swipe had already killed it, so the cooldown only moved once ~14 bodies were present.

The weapon message was lost when the heal popup fired

showModal deliberately never clobbers an open modal, so winning a duel while wounded opened the heal modal and the weapon modal was silently dropped. deliverTin(healLine) now folds the trainer into the WEAPON popup as an extra line, so there is one popup carrying both. An unwounded duel win still shows the plain weapon popup.

The mop boy walked out with nothing to mop

sendBoy now returns early when mop is set and no stain passes the same test mopRound uses to pick a target (a >= 0.15 && z >= COURT.netZ - 2) — he was walking on, finding nothing reachable, and walking straight off. This needs state.js to read gore, which adds a third runtime-only import cycle (state <-> zombies) alongside input<->gameplay and zombies<->gameplay; it is safe for the same reason, gore is only touched inside sendBoy at runtime, never during module evaluation.

Regression net

30 tapped tennis points = 50 hits / 0 whiffs. 45-game soak: no exceptions, all four chapters, mgT drains, gore 40 / piles 30 / bodies capped at 34.


CLOSED — Oliver's third playtest: audio, hit timing, readability (2026-07-30)

The umpire talked too much and at random

Rebuilt as situational triggers behind one shared cooldown — see the invariant above. asideEvery 26 s -> 55 s. Removed ump_out_of_order, ump_let, and ump_no_appeal (he asked for "ump_no_deal"; that line does not exist and ump_no_appeal is the one he meant — confirm if a different line is still unwanted). 11 ump_* lines remain.

"Visually the ball is not yet at the player but somehow the player already is hit"

Real, and measurable. The hostile-ball hit test reused the PARRY's hypot(dx, dz * 0.8) — weighting depth by 0.8 SHRINKS it, so contact fired while the ball was still most of a metre up-court. Measured at the moment of the hit: dz = -0.68 m, and the ball drawn at screen y 314 against a head at 305 — up around his head, not on his body. Fixed with a separate unweighted test (TUNE.shotHitR 0.42, shotHitY 1.75). Re-measured: contact at dz ≈ -0.25 m, ball at screen y 325 against head 305 / feet 362 — squarely on the chest. The parry keeps its 0.8 weighting on purpose: a racket sweep really is wider than it is deep.

"Everything is a tad too small" (his wife, and the best note of the project)

Measured before changing anything, because "regenerate the sprites at higher resolution" is only half right. With RES 3, drawn DEVICE pixels vs the 112px source were:

player at baseline   170 px   UPSCALED 1.52x   <- higher-res art helps
shambler your half   120 px   upscaled 1.08x   <- helps
shambler mid-court    98 px   downscaled       <- cannot help; needs to be BIGGER
shambler far          78 px   downscaled 0.70x <- cannot help

So near bodies needed resolution and far bodies needed the camera. Both were done.

Camera (portrait only; landscape untouched): camZ 42->48, camF 580->760, camY 9->11, horizon 90->45. Pushing the camera back while lengthening the lens FLATTENS the perspective, so distant bodies grow relative to near ones instead of everything merely zooming; camY rises with it for a steeper look-down so bodies overlap less at depth, and horizon re-centres the court after both. Result: far shambler 26 -> 30 logical px, mid 33 -> 36, chapter-1 boss 39 -> 45, player unchanged at 57, court band 200..390 (was 199..380). If you retune these four, check all four outputs: far size, near size, court top, court bottom.

Sprite remaster, at zero generation cost. The original assets/_sheet_*.png files are retained at full generated resolution (~1400px wide), so nothing needed regenerating — IDLE_TARGET_H went to 224 and every sheet was RE-SLICED. The sheet-to-output mapping was never written down, so it was recovered automatically: slice each sheet to a temp dir, then greedily assign frames to existing assets 1:1 by LANCZOS-normalised RGBA signature distance. 58 sprites came back from sheets; the 29 with no confident sheet match were 2x-upscaled instead, which adds no detail but keeps them on the shared scale (see the SPRITE_H invariant — a mismatched sprite renders at half height). x_crowd/x_crowd2 were reverted to originals: they never touch SPRITE_H and doubling them is pure memory. Known gap: p_idle1/p_idle2 and most o_* frames were among the 2x-upscaled set, so the two most-seen poses gained scale but not detail. Re-slicing _sheet_player2/3 and _sheet_opp2/3 by hand (rather than via the matcher) would fix that.

Regression net

30 tapped tennis points = 50 hits / 0 whiffs. 45-game soak: no exceptions, all four chapters fired, mgT drains, gore 40 / piles 30 / bodies capped at 34.


CLOSED — Oliver's second boss/ranged playtest (2026-07-30)

The parry was too generous

"I can tap pretty early and it returns the ball." At parryArm 6.0 / window 0.34 / reach 2.4 the shot flew INTO reach during the window, so any tap once it was vaguely close worked. Now 3.4 / 0.2 / 1.7 — verified: a tap at 6.0 m or 4.5 m is rejected and costs blood, 3.2 m and closer returns.

The boss's three balls were decoration

"he throws 3 of them from which only ever one can reach me. that does not make sense." Correct — they were fanned +-1.9 m across space, so only the middle one was ever aimed at the player. They are a timed salvo now: three shots TUNE.bossSalvoGap apart, each aimed at where you are AT THAT MOMENT with only a hand's width of scatter. Verified firing at 0.02 / 0.45 / 0.88 s, all within 0.3 m of the player. Note hurtGrace (1.1 s) still caps a whole volley at one point of damage by design. Reaction time also tightened: bosses get bossWind 0.5 s of telegraph against the machine's 0.9, and throw at bossShotSpeed 15.5 m/s against its 13.

Every chapter was the same boss

Right — chapters only differed in hp and scale. There are three designs now, cycling, each with its own sheet, a 2-frame stride, a throw pose, a wrecked skin and a wrecked-throw skin: THE GROUNDSKEEPER (ch 1/4/7…, blazer + ball hopper, and the only one with three wound stages), THE LINE JUDGE (ch 2/5/…, fat, striped officials' polo, cap, ball apron), THE CHAIR UMPIRE (ch 3/6/…, tall and gaunt, panama hat, the umpire's chair strapped to his back). The chapter card names the design. Chapter 4 repeats design 1 but at 43 hp and 3.67 m. The chair-umpire sheet came back on a WHITE background, and it wears a cream blazer and a white hat — a colour key would have eaten both. Fixed by flood-filling the background inward from the image BORDER instead of keying by colour; that script is in this session's git history and is the right tool whenever a generated sheet's background colour also appears on the character.

Healing needed to announce itself

Winning a duel while wounded now opens a real modal — x_cross first-aid tin, "WOUNDS TREATED / THE TRAINER PATCHED YOU UP / N OF M BACK" — plus an umpire line. It was a 1.4 s flash before.

The umpire needed to comment on the new reality

14 new recorded lines (say -v Daniel -r 168 -> afconvert), all ump_*. Ten are a rotating pool of sarcastic asides fired on a jittered TUNE.asideEvery timer whenever nothing else owns the audio (umpireAside in gameplay.js); four are hooked to specific beats — a machine arriving, a boss arriving, the trainer, and taking a hit. The joke is that he has still never acknowledged that the match became a massacre, so he keeps officiating it: "Code violation. Unsportsmanlike conduct. Eating."

Regression net

30 tapped tennis points = 50 hits / 0 whiffs. 45-game soak: no exceptions, mgT drains, gore 40 / piles 30 / bodies capped at 34.


CLOSED — Oliver's first boss/ranged playtest (2026-07-30)

Six reports, all fixed. Two were serious.

A boss sprite appeared in the shambler crowd — CASE COLLISION

z_B1.png / z_B2.png overwrote the 'b' shambler's z_b1.png / z_b2.png on a case-insensitive filesystem. Restored from HEAD~1; boss frames renamed z_boss1, z_boss2, z_bossw1..3. The rule is now in the asset pipeline section — this is a trap that leaves no obvious trace.

The parry was unreachable — the whole mechanic was dead

"I could not return a single ball of the ballmachine.. I tried it with taps near the hit - it never worked", and the same for the boss's volleys. Cause: P.swingT is only set in keyPressed's phase === 'rally' branch, and splatter is in pretoss almost all of the time, so the tap tossed the serve ball instead of swinging. An incoming shot now owns the tap in every phase — see the invariant above. Verified returning from pretoss, rally and over, at 0.9 m through 5.4 m, with the three fail cases (too early / too late / no tap) intact.

One flame ball killed a 16 hp boss

Not tuning — a real bug. The flame ball is not consumed on contact (full pierce) and a boss is not removed when hit, so it re-wounded every frame. zb.hitT + spending the ball on a boss. Chapter 1 now costs 4 flame balls or 16 clean smashes, and bossHp went 10 -> 16 (bossHpPerChapter 8 -> 9), so chapter 4 is 43 hp.

The boss had no wounded state while throwing

Correct — the wound skins were standing poses only. New sheet _sheet_bossthrow.png gives all four damage states their own overarm pitch (z_bosst0..3), used whenever windT > 0.

The ball machine was "naively painted"

It was — four rects. Replaced with a generated sheet (z_mach1 idle, z_mach2 firing with a muzzle flash): a dented hopper cart on wheels with a barrel aimed down-court. The muzzle GLOW stays procedural on top, because the telegraph has to read at 20 px tall.

The player's blood read as a painted layer

Removed entirely. See the invariant above.

CLOSED — chapter bosses + ranged attacks, steps 4-5 (2026-07-30)

Step 4 — the ranged channel

The ball machine ('m'), Oliver's own idea and the most on-brand thing on the list, because a ball machine is real tennis furniture. It never walks, so it never crosses your baseline and never scores: it is a standing threat you choose when to deal with, which is the target-priority decision the game was missing. Measured: 3.4 s cycle, 0.9 s telegraph (TUNE.machWind, muzzle glow + a mechanical click — the same fairness contract the runner's shriek() is), 3 hp, exempt from the clear gate.

The projectile is a real ARC, solved like a serve rather than fired flat, so it reads coming in and so the swing window has something to meet. fireAtPlayer aims where you stand, which means moving is a partial answer during a rally — and is exactly why the swish path exists for pretoss, where you cannot move or swing.

Player damage. 4 hp, hurtGrace 1.1 s, +2 on a won duel (the only healing in the game, and now THE reason to want a duel rather than tolerate it). Verified: 4 hits kill you and land in the same eaten path as being caught, with eatenAt, the death animation and the best-run write all intact; three hits in one frame cost only one point.

Step 5 — chapter bosses, and the racket zombie demoted

Demoted, as Oliver asked. The racket zombie used to respawn on every game you won, which is precisely why he read as "the boss" and why a real boss could not have stood out next to him. He is an occasional opponent now (TUNE.racketEvery = 3 games).

The chapter boss ('B') arrives every TUNE.bossEvery = 10 games, fired from proceedAfterClear — the one beat where the court is guaranteed empty — and it OUTRANKS the set/horde and duel beats there, because a boss walking on into a standing wave would be unreadable. It clears the stage, blocks all other spawns while it lives, and threatens both channels: volleys of bossVolley = 3 chip your hp, and letting it reach you is the ordinary instant death. Chapter 1 is 10 hp and 2.7 m; chapter 4 is 34 hp and 3.67 m.

The economy fix this was really for: tins arrive every 3 games forever, so nothing was ever worth banking. Measured cost of a chapter-4 boss — 12 bombs, or 100 machine-gun rounds, or 34 side balls. TUNE.wCap is 9 per type, so no single weapon can kill a late boss and a mixed, banked arsenal is mandatory. That is the strategic pressure Oliver was asking for.

G.bossDue never moves backwards (Math.max): going a long stretch without ever clearing the court skips a chapter rather than queueing a backlog. Deliberate — you face the boss the run has earned.

Bugs found and fixed while building this (all were silent)

  • A blast deleted a 34 hp chapter boss outright: explode only wounded 'r'. This is what isBoss() now exists to prevent, and it is the second time the same class of bug has shipped.
  • The machine's machHp did nothing — 'm' fell through every variant branch to the generic one-shot fallback. It soaks like the brute now (3 balls, verified).
  • The boss both scored a kill and fired its chapter card, so two code paths set "what happens next" in the same frame.
  • The machine gated the hand-clear, turning a priority decision into a chore.
  • A boss death produced a shambler-sized gore burst (GORE_MUL had no 'B'; now 6 → ~305 particles).

Regression net after steps 4-5

30 tapped tennis points = 50 hits / 0 whiffs (unchanged — tennis is untouched by any of this). 45-game splatter soak: no exceptions, all four chapters fired [1,2,3,4], machines appeared, ranged hits landed, mgT drains, gore 40 / piles 30 / bodies capped at 34.


CLOSED — the 50-game plateau, steps 1-3 (2026-07-30)

Racket zombie's HP bar removed

See invariants. Replaced by a one-per-run "IT HAS A RACKET / IT RETURNS YOUR WEAK SHOTS / ONLY A FULL PULL WOUNDS IT" modal from spawnRacketZombie, gated on G.seen.racket.

"Sometimes the racket zombie doesn't seem harmable" — it was real, not a feeling

Three of the five rack slots did nothing at all to him, silently: TRIPLE side balls skipped him (variant === 'r' → continue), GUN/horde pellets skipped him, and fire spread could not ignite him. Meanwhile BOMB deleted him outright ignoring hp. All fixed via the new TUNE.dmg* table and wound(). Verified numerically: 9 pellets kill a 3 hp boss, one side ball takes 1 hp and is consumed, a weak side ball does nothing (correct — the zSplatQ gate), a bomb takes 3 off, and he now catches fire from a burning neighbour and cooks to death in 3.6 s at bossBurnMul 0.4.

  • hitR(zb) gives him a 0.7 m contact radius (shambler 0.55, brute 0.75) — part of "unharmable" was simply missing him.
  • Melee is still skipped, and that is correct, not an oversight. The player is bounded at P_BOUNDS.zMin = 13.1 and the boss holds z <= COURT.svcFar = 5.5, so they are always on opposite sides of the net and meleeRange 1.7 can never span it. Don't "fix" an unreachable path.
  • A sub-threshold ball still passes through him on purpose so racketAI can rally it back. That rally IS the feedback that the pull wasn't hard enough, so there is deliberately no clank there.

The director un-capped

Old vs new, verified in-browser (gap s / body cap / speed m/s / advanced share / boss hp):

       gap        cap       speed      nasty      bossHp
g 1  2.59/2.78   3/4     0.79/1.75   0.03/—      3/3
g10  1.15/1.00  12/13    1.61/1.75   0.30/0.27   4/3
g12  1.03/1.00  13/14    1.79/1.75   0.36/0.42   4/3
g20  0.71/1.00  21/14    2.52/1.75   0.60/0.42   5/3
g30  0.52/1.00  24/14    2.73/1.75   0.75/0.42   6/3
g50  0.35/1.00  24/14    2.73/1.75   0.75/0.42   9/3
  • The spawn gap is hyperbolic now (zSpawnEvery / (1 + games * zSpawnAccel)) so it keeps tightening instead of hitting a floor; zSpawnMin is only a safety stop.
  • Known, deliberate early-game change: shamble speed used to be TIME-based (splatT / 70) and maxed out after 105 seconds of splatter — i.e. during game 2 — so it was never a progression lever and every early shambler already walked at top speed. It is games-based now, which makes games 1-10 genuinely gentler than before. Oliver has not playtested that yet; if the on-ramp feels flabby the knob is zSpeedPerGame.
  • Hordes scale with sets banked (hordeGrow) — length AND spawn rate. TUNE.zHordeCap = 34 is required: a horde deliberately ignores the director cap, and with growth a late wave asks for ~120 spawns where the old fixed 12 s x 2.5/s could only ask for 30. A 41-body court showed up in the soak before the cap went in.

Regression net after all of it

30 tapped tennis points = 50 hits / 0 whiffs (unchanged). 40-game splatter soak: no exceptions, mgT drains to 0, gore 40 / piles 30 / bodies capped at exactly 34.


CLOSED — Oliver's 35-game retest (2026-07-27), all fixed 2026-07-30

Kept because each one is a trap worth not re-digging. Every fix was verified in the browser via window.T; the numbers quoted are from those runs.

1 + 6. Player frozen (and stuck mid-animation) during the between-game pauses — was HIGH

Two fixes: the first was wrong. The movement block — pose update included — lived inside if (G.phase === 'rally'), so every 'over' beat and every pretoss left the player standing while zombies walked at him. The pretoss case is issue 6: endHorde calls setupPoint().

The first attempt widened the block to all of splatter except 'toss'. Oliver vetoed it on sight: "the mechanic where I suddenly can move freely around feels broken.. I basically can never walk around freely - also I could walk off the screen". He is right — free movement for five seconds between points, in a game where you otherwise only move while a ball is live, reads as a bug. It is reverted, and the rule is now in Settled feel decisions above.

What actually ships, three separate pieces:

  • The dead wait too. held = !!G.changeover in updateZombies stops every zombie's z advance, its x steering, and the eat check for the breather's duration. They still bleed, still burn, still animate — they just stand there, because the umpire called time and it is still a tennis match. This is Oliver's own second option and the characterful one (the boys mop, everyone waits).
  • The stuck pose. expirePose only ever expired swing/serve; 'run' was the pose nothing reset, and P.runT stops advancing outside the movement block, so a beat that began mid-stride welded him to one run frame. An else if (P.pose === 'run') on the movement block fixes it for every phase at once.
  • The on-screen clamp (see Settled feel decisions) — xVis already existed but was applied only in auto-fire modes, so ordinary play could walk clean off the edge. Now it always applies.

The path where this actually bit — worth knowing, because a clear court makes it look impossible: a changeover normally starts only after G.clearing has emptied the court, so nothing is walking. But resolveInterlude('O') spawns five fresh zombies ("the dead lose their patience") and then hands back to startChangeover via G.afterDuel. Lose a duel scheduled on a changeover beat and you get five shamblers plus five seconds with no agency. Verified fixed: they advance 0.000 m across the breather and resume 0.75 m/s the instant "Time." is called.

If the 5 s still drags on a phone, TUNE.changeoverT is the knob — it is now a safe, watchable beat rather than a dangerous one, so shortening it is purely a pacing call.

2. The mop boy walks on during the 1:1 duel — was LOW

sendBoy now returns early on G.interlude, and startDuel sets G.boy = null. Note the ordering that makes this safe: resolveInterlude('P') clears G.interlude before calling deliverTin(), so the boy still comes out with the tin.

3. Triple serve: the two wing balls lob off at the wrong height — was MEDIUM

The diagnosis in the old text was right. g: ball.g is now copied onto each clone (both push sites: hitServe's triple fan and dragServe's re-fire clone) and zombies.js integrates e.vy -= (e.g || TUNE.gravity) * dt. Verified at full pull: clones carry g = 21.16 (2.3 × the 9.2 base) instead of 9.2, and centre and wing apex heights now match exactly (2.90 m both).

4. The umpire calls "Time" on pauses that aren't changeovers — was LOW

G.changeover is now the seconds left, not a flag (still truthy, so every !G.changeover guard reads the same), and it counts itself down near the top of update(), calling voice('time') when it hits zero. It no longer rides the generic G.overT expiry — anything that overwrote overT mid- breather (a melee kill taking a game, say) used to make the umpire call time on a pause that was never a changeover. Verified with a fetch spy on assets/audio/time.m4a: an unrelated overT expiry at t=0.4 s says nothing and leaves 4.6 s on the breather; the line fires exactly once, when the breather's own clock runs out.

5. The transition tutorial illustration confuses people — was MEDIUM

assets/x_howto.png regenerated (Gemini 3 Pro, edit mode with the old sheet as --input, then assets/_key.py … 240). The curved arrow and thumbprint are gone; it is now a press ring at the top, a straight vertical dashed track, two chevrons pointing DOWN, and a fingertip at the bottom — with the yellow shot arrow flying the opposite way to the zombie, which is the inversion lesson. Aspect is unchanged (240×321 vs 240×322), so the modal still draws it at 90×120 in portrait. Checked at that drawn size, not at full resolution.

7. Aiming plays the toss animation; it should stay in the pretoss stance — was MEDIUM

update()'s forced sv.pose = 'serve' for phase === 'toss' is now gated on !G.aim: while the slingshot is drawn the server holds the pretoss stance, and only hitServe puts him into the toss/smash. Verified: plain tap → pose serve; drawn bow → pose idle.

8. Show the current game number persistently — was LOW

GAME N now sits permanently under the scoreboard, right-aligned to the board's width, on the same row as the NEXT pips and in the same dim tone (render.js, just before the pips block). The 1.1 s flash when a game arms is still there.