# 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-27 after Oliver's 35-game retest. **Open issues are at the bottom — start there.** ## 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. ## 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. - 30 human-tapped points (tap when the incoming ball is 2.2–3.2 m away) = **50 hits / 0 whiffs**. - 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. - **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. --- # OPEN ISSUES — from Oliver's 35-game retest (2026-07-27) Nothing below is fixed. Diagnoses are from reading the code this session; verify before trusting. ### 1. Player frozen (and stuck mid-animation) during the between-game pauses — HIGH During the ~5s changeover the player cannot move but the zombies keep walking. The whole movement block, including the `P.pose = moving ? 'run' : 'idle'` update, lives inside `if (G.phase === 'rally')` in `update()`. Every 'over' beat (changeover, game beat, set card) therefore freezes the player in whatever pose he held while `updateZombies` runs on. **This is almost certainly also issue 6** (freeze after the horde) — `endHorde` hands back to a phase where the movement block doesn't run. Oliver's stated options: *drop the 5s entirely, or stop the zombies for its duration.* Stopping the zombies is the more characterful of the two (the boys mop, everyone waits) but letting the player walk during 'over' in splatter is the smaller diff and fixes the stuck pose too. Consider both. ### 2. The mop boy walks on during the 1:1 duel — LOW `sendBoy` has no `G.interlude` guard and `startDuel` doesn't clear `G.boy`. The duel is meant to be clean tennis; nobody should be mopping through it. ### 3. Triple serve: the two wing balls lob off at the wrong height — MEDIUM Confirmed cause. The real serve carries topspin gravity `ball.g = TUNE.gravity * (1 + quality * TUNE.serveSpin)` — up to **2.3×** `TUNE.gravity` at full power. The clones in `extras` are integrated at flat `TUNE.gravity` (`zombies.js`, the `for (let i = extras.length - 1 …)` loop: `e.vy -= TUNE.gravity * dt`). So the centre ball dives and the wings float long. Fix: copy `g: ball.g` onto each extra where they're pushed (`gameplay.js` in `hitServe`, and the re-fire clone lower down) and integrate with `e.g || TUNE.gravity`. ### 4. The umpire calls "Time" on pauses that aren't changeovers — LOW `voice('time')` fires in the `over` branch of `update()` whenever `G.changeover` clears. Needs to distinguish a real changeover from other forced waits, or the line needs to move. ### 5. The transition tutorial illustration confuses people — MEDIUM `assets/x_howto.png`. The curved arrow plus thumbprint reads as a rounding/scroll gesture rather than a pull-back. Oliver: *"maybe display a pulling and aiming finger?"* Worth regenerating as a literal finger pressing down and dragging back with a motion trail, and re-checking it at the modal's drawn size (~89×120 logical px in portrait) rather than at full resolution. Note `drawPullHint()` now demos the gesture live in-game, so the illustration can lean on that. ### 6. Sprite freeze after the horde — HIGH See issue 1; very likely the same root cause. ### 7. Aiming plays the toss animation; it should stay in the pretoss stance — MEDIUM `input.js` touchmove calls `serveToss()` on the first drag, which sets `G.phase = 'toss'`, and `update()` forces `sv.pose = 'serve'` for the whole of `phase === 'toss'`. So the player is in the toss/serve pose the entire time he is aiming. Oliver wants the **pretoss** stance while aiming, with the toss/smash playing when the shot actually fires. ### 8. Show the current game number persistently — LOW Today it only appears as a 1.1s `GAME N` flash when the next game arms. Wants it visible somewhere permanent — the scoreboard is the obvious home (it already has a sets column in splatter).