# HANDOFF — Splatter Tennis **This file is the only planning document.** `PLAN.md` and `POLISH-PLAN.md` are deleted — everything they specified is shipped, and what still matters from them is recorded below. For the long history, `~/.claude/projects/.../memory/splatter-tennis-history.md` is auto-loaded; for the detail of any individual change, read the git log. Last updated 2026-07-30 after Oliver playtested the fixes. All eight retest issues are closed and recorded at the bottom with what the fix was, because each one is a trap worth not re-digging. Issue 1's first fix was **vetoed on feel and replaced** — read that entry before touching movement. ## 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` (python http.server :8641, `.claude/launch.json`) - **Deploy**: push to `git@gitea.wuwi.one:wuwi/splatter-tennis.git` (main) → Coolify webhook builds the Dockerfile (nginx, no-cache). Oliver tests on his phone at splatter-tennis.wuwi.one ~30s after a push. **Bump `main.js?v=N` in index.html every time** or phones keep the old build. The Dockerfile must also COPY any new top-level file (it currently ships `index.html`, `manifest.webmanifest`, `*.js`, `assets/`). - **Oliver edits files himself** (tuning values, sometimes sprites). ALWAYS Read current file state before editing; never assume your last version is what is on disk. ## Module map (all in repo root) `config.js` TUNE (live T panel) + structural constants + `UI` palette · `state.js` the `G` object + P/O/ball/score + `setupPoint`/`sendBoy` · `gameplay.js` ballistics/swings/serve/rules/AI/`update()` + transition + `splatPoint` + ball-boy routines · `zombies.js` zombie/gore/particle state + `updateZombies` · `render.js` camera/palette-lerp/sprites/all drawing · `input.js` keys/touch/flick · `audio.js` sfx + recorded umpire (`assets/audio/*`, macOS `say -v Daniel`) · `panel.js` the T panel · `main.js` boot, loop, umpire-tap cheat, `window.T` test surface. Two runtime-only import cycles (documented in headers): input↔gameplay, zombies↔gameplay. ## 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`). Weapons are **only ever won**, in the **sudden-death duel** (`G.interlude`) against the racket zombie. Getting caught = `G.mode='eaten'` = permadeath, and the run summary is the whole game. ## Architecture & invariants — know these before touching anything - **`G.modal` is the only thing that stops the world.** `update()` early-returns on it (after flash/card decay), so no zombie steps, no ball flight, no spawn timers — only `idleT` ticks for the blink. `showModal`/`dismissModal` in gameplay.js; `drawModal` is the LAST thing render draws. Dismissal must **swallow the gesture**: input.js `touchstart` returns on it, keydown returns on it, and the `pointerdown` dismisser is registered **only on `pointer: fine`** — on touch, pointerdown fires *before* touchstart, so a shared handler cleared the modal and let the touchstart underneath serve. `G.seen.` gates first-time-only modals. - **`UI` in config.js is the accent palette** (hot/warm/pick/cream/dim/bad/ball). Every HUD colour goes through it. Gore reds are deliberately NOT in it — the splatter stays loud. - **`F(size, bold)` in render.js builds every canvas font string.** Bare `monospace` resolves to Courier, a slab SERIF — that was the "generic serif" bug, not taste. Never hand-write `ctx.font`. Widest string checked fits 270px portrait with ~10px spare. - **Weapons are only ever WON.** `deliverTin()` has exactly one caller: `resolveInterlude('P')`. The schedule (`TUNE.duelFirst`/`duelEvery`, `G.duelDue`) calls the **duel**, fired from `proceedAfterClear()` — the one beat where the court is guaranteed empty. The game beat it interrupts is parked in `G.afterDuel` and resumed when the duel resolves. - **Anything that blocks serving.** If a "can't serve" report ever comes in, check these first — each is an early return in `keyPressed`: `G.mgT`, `G.horde`, `G.changeover`, `G.interlude.introT`, `G.modal`. `G.mgT` in particular must keep counting down in EVERY phase (it used to tick only during `rally`, so a duel calling `setupPoint()` stranded it above zero forever = permanent lock). - **Animation frames step off DISTANCE COVERED, never elapsed time** — `boy.dist` for ball boys, `zb.z / TUNE.bruteStride` for brutes. Time-driven cycles moonwalk the moment speed and frame rate disagree. Sprite heights follow the PNG (`img.height / SPRITE_H`) because `_slice.py` common-scales a sheet; a wound stage's height must ALWAYS come from its A frame or it pops. - **Fire chains.** `zb.burnT`/`burnD`; `ignite()` is read-only over `zombies[]` so it is safe inside a kill loop; `burnTick()` returns true when the fire killed the body and the caller MUST `continue`. The flame ball also ignites what it FLIES PAST (`TUNE.burnBallR`) — without that its full pierce killed its own lane outright and nothing was left to catch. Flames are drawn procedurally (`drawFlames`), no sprite sheet. - **Any loop over `zombies[]` whose body can kill** can cascade into a sweep and gut the array mid-iteration — keep the `if (!zb) continue` guards and iterate backwards. - **The ball boy is ONE slot.** `sendBoy` never replaces a boy already on court (a kill streak used to teleport him back to the wing several times a second); new work is appended via `jobs`. `mopRound` walks him to a stain, holds him there for `TUNE.boyMopDwell` while the push cycle animates in place, then moves him on. Three sheets: `x_boymop` (stopped, working), `x_boycarry` (walking with the mop shouldered), `x_boyrun` (tennis-mode fetch). - **Crowd applause is a real two-frame swap** (`x_crowd` seated / `x_crowd2` arms up), per column on its own phase, with **no vertical offset**. A fractional `dy` on a scaled image re-snaps its pixel rows every frame under nearest-neighbour — that boiling beside the HUD was the "scoreboard flicker" (the board art is 99.9% opaque; nothing was ever showing through it). **If you touch one crowd frame you must apply the identical curve to the other**, or the swap starts flashing brightness. `assets/_sheet_crowd1.png` is the pre-brightening original. ## Settled feel decisions — do not regress silently - **Two-stage serve**: touch = reticle, drag = aim, release = fire, drag back under `pullMin` = cancel. The pull's DIRECTION picks the lane, its LENGTH is power — deliberately decoupled so a far shambler never costs a longer drag than the screen has. - **The manual wave clear** after a won game (`G.clearing`). Oliver was emphatic. - **Thin, dynamic blood.** Open wounds gush live particles (`bleed()`); blood is never painted into a sprite. Gore piles/stains are the loud part; keep the trickle sparse. - **One gesture means one thing** on touch. No mode where a drag silently becomes something else. - **Volleys**: pre-bounce contact gets reach ×1.4 / window ×1.8 (`VOLLEY`). A RISING ball is hittable at any height; only a dropping ball below `CONTACT.minY` is past you. - **Aim magnet**: `flickTarget` picks the best-ALIGNED body, with `TUNE.aimTie` of slack in which a nearer one still wins, so a straight-ahead lane still eats the nearest body first. `aimTie: 1` restores the old "nearest along the lane always wins" for A/B. - `TUNE.fps` 60 — 30 was vetoed on feel. - **The player only walks during `rally`.** Never between points. Letting him walk during the 'over' beats and `pretoss` was shipped once and vetoed immediately: *"I basically can never walk around freely"* — so suddenly being able to, only between points, reads as the game breaking rather than as a fix. Frozen-with-danger-approaching is fixed by holding the DEAD instead (`held` in `updateZombies`), never by handing back movement. - **The player must always be ON SCREEN.** `P_BOUNDS.x` is 6.5 m but portrait only shows about ±4 m either side of the centre line at the baseline, so the walk bound alone let him leave the frame entirely. `xLim = Math.max(xVis, COURT.W / 2)` — the camera limit, floored at the sideline so every legal ball stays catchable. ## 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()`. - **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): - CPU returns ~11/12 serves (91.7%). Recent 150-sample runs: 90.7%, 89.3%, 90.7%, 88.7% (±2.5%). Watch this — the last few sit under the bar rather than scattered around it. **The harness for this number was never written down and I could not reconstruct it.** A rig that counts "did the CPU get a racket on the served ball" scores 100/100, because after a flub the CPU gets another swing at the SAME ball (`aiSwingCool = 0.5`) and connects — 91.7% ≈ 1 − aiFlub, so the recorded number must count only the FIRST swing opportunity, or only returns that land in. Until someone writes the rig down, prove "no tennis regression" **differentially** instead: run your rig, `git stash` the change, reload the modules, run the identical rig, compare. Equal numbers on the same rig is the real claim; a bare percentage against this line is not. - 30 human-tapped points (tap when the incoming ball is 2.2–3.2 m away) = **50 hits / 0 whiffs**. This one DOES reproduce exactly — drive it off `G.missLog` (HIT vs MISS counts). - A 26-game state-machine soak with bombs/bursts/fire must finish with no exceptions, no stall, `mgT` back to 0, and gore/piles arrays bounded (gore caps at 40, piles at 30). **Two ways a soak lies to you**, both cost me a run: force-spawning zombies during `G.clearing` makes the hand-clear un-completable and reads as a 30-minute stall; and reviving the player after each `eaten` inflates gore past its cap, because the death push (`zombies.js`, the `eatenR` hit) is the one uncapped `gore.push` in the file — fine at one death per run, not at 260. Respect `clearing`, and either end the soak on death or set `TUNE.eatenR = 0` to make him untouchable. - **Never trust a single sample.** aiFlub is 9%; a 20/24 run is normal noise. Use 150. ## Asset pipeline 1. `generate-image` skill (needs `OPENROUTER_API_KEY` in the gitignored repo `.env`; run with the scratchpad venv python — system python lacks `requests`). Passing the existing sheet as `--input` (edit mode) nails the style and is how every matching sheet here was made. 2. Ask for a **magenta background** and, for sheets, "at least 150px of empty magenta between frames" plus "the prop must stay inside its own frame". 3. `python3 assets/_slice.py sheet.png out1.png ...` — chroma-key on the corner colour, magenta de-fringe, column-gap segmentation, common-scale per sheet, `IDLE_TARGET_H=112`. `--largest` keeps only the main blob. 4. Standalone art (logo, popup panels) uses `assets/_key.py` instead: chroma-key + crop to the content bbox + optional downscale, no frame splitting, no forced height. 5. Register new names in `render.js`'s `SPR` preload array. 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. ## Gotchas learned the hard way - **Stale ES modules are the #1 time sink.** python http.server sends no cache headers, so an edited module often loads STALE. `preview_stop`/`preview_start` only helps if the port actually changes, and holding 8641 with a throwaway socket **did not work reliably**. What DOES work: ```js await Promise.all(['main.js','state.js','gameplay.js','render.js','config.js','zombies.js', 'input.js','audio.js','panel.js','dom.js'].map(f => fetch(f, {cache:'reload'}))); ``` then reload the page. **Always sanity-check a just-added field before trusting any result** — and note the trap: if `state.js` is stale but `gameplay.js` is fresh, a new `G` field materialises as **NaN** (undefined + dt), which reads exactly like a logic bug. - `drawChar` mirrors sprites by `ch.facing` — any stance that must not mirror needs facing reset. - Synthetic touch tests: input.js only sets `stick.moved` in touchmove, so touchstart→touchend reads as a TAP. Always dispatch a touchmove between them. - Contact/kill tests must step 1/60 — pellets at 26 m/s tunnel past 0.55 m radii at coarser steps and kills silently don't happen, which looks exactly like a bug. - **When testing fire or any serve, fire a real ARC.** A flat ball from z=16 hits the net (netH 0.914) and bounces back at you; a "the weapon is broken" reading was my test hitting the tape. - The scratchpad venv gets wiped between sessions — recreate it with `requests` + `pillow`. - Commit style: imperative summary + `Co-Authored-By: Claude Opus 5 `. Push = deploy. --- # 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.