# HANDOFF — Splatter Tennis (for the next Claude session) Read this first, then `~/.claude/projects/.../memory/splatter-tennis-history.md` (auto-loaded) for the long history. This file is the precise mid-task state as of 2026-07-11. ## What this is SNES-style pseudo-3D tennis (Super Tennis reference) that transitions into a zombie splatter game while keeping all tennis aesthetics (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 (tap = swing/serve, flick = aimed shot, auto-run assist does positioning). - **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 auto-builds the Dockerfile (nginx, everything served no-cache). Oliver tests on his phone at splatter-tennis.wuwi.one within ~30s of a push. - **Oliver edits files himself** (tuning values, sometimes sprites/sheets). ALWAYS Read current file state before editing; never assume your last version is what's on disk. ## Module map (all in repo root) `config.js` TUNE (live panel, T key) + constants · `state.js` G object + P/O/ball/score + setupPoint · `gameplay.js` ballistics/swings/serve/rules/AI/update + transition + splatPoint · `zombies.js` zombie/gore/particle state + updateZombies · `render.js` camera/palette-lerp/sprites/drawing · `input.js` keys/touch/flick · `audio.js` sfx synth + recorded umpire (assets/audio/*.m4a, made with macOS `say -v Daniel`) · `panel.js` · `main.js` boot, loop, umpire-tap cheat, window.T. Two runtime-only import cycles (documented in headers): input↔gameplay, zombies↔gameplay. ## Zombie mode — how it works today - Trigger: win your first game in tennis mode, OR **triple-tap the umpire** (x=7.1, netZ) within 1.6s (cheat; listener in main.js → `forceZombies()` in gameplay.js). - Transition (`G.mode='transition'`, timeline in gameplay.update via `G.transT`): applause cut, palette lerp (PAL in render.js), umpire "Quiet… please" at low playbackRate (voice() has a `rate` param), CPU opponent swapped for zombie-CPU (variant 'c'), scripted first spawns, then `G.mode='splatter'`. - Splatter: you serve at zombies; every ball carries `ball.pow` (= shot quality); hit with pow >= TUNE.zSplatQ → splatter (gore decal + particles + splat() sound + splatPoint('P')), else stagger/pushback and the ball is spent (`lastHit='X'`). Zombie crossing your baseline → splatPoint('O'). Tennis scoring machinery keeps running (deuce, advantage, "Game, player"). Ball boys mop gore they pass. HUD shows THEM instead of CPU. - Balance knobs in TUNE (T panel): zSpeed, zSpawnEvery, zMax, zSplatQ, zPushback. ## STATUS — plan v2 COMPLETE + three feedback rounds (2026-07-12) Everything in PLAN.md is shipped: match structure (games/sets/changeovers, permadeath, spawn director), bomb juice, new-balls weapon ladder (BOMB→TRIPLE→FIRE→GUN), enemy roster (runner/brute/shield), game-end sweep + 'GAME N' theater, an always-there racket-zombie opponent, and the SUDDEN DEATH interlude (intro card, tennis-palette lerp while the duel runs, real line calls, win = tin on the spot, lose = surge). Post-plan rounds added: TWO-STAGE serve aiming (touch = reticle + auto-lock, drag = aim, release = fire, drag-back = cancel; aimed serves commit specials, taps keep them), the RAF-freeze fix (sweepCourt guts zombies[] under iterating loops → null guards everywhere a kill can cascade + RAF rescheduled before update), instant absolute machine-gun steering clamped to the visible court, RES 3 + flatter lens (camZ 42, camF per orientation in fitViewport), 4-frame walk cycles (z_a/b/f 1–4), tiered gore (GORE_MUL in zombies.js), generated stadium (x_crowd.png bounce-columns, x_ads.png sponsor boards, umpire at UMP_X 5.6 — 7.1 was one pixel off-screen in portrait), runner = glass cannon (any touch kills; runnerVMax cap), battery pass (TUNE.fps knob default 60 — 30 was vetoed on feel; cached pile canvases). PLAN.md records all decisions + next-arc candidates. Next: Oliver playtests and tunes via the T panel. ## PREVIOUS TASK — all four items from Oliver's feedback round are DONE and VERIFIED (serve cadence · flick-aim anywhere incl. over the net · zombie speed ramp · eaten): 1. **Serve cadence — verified.** Dead splatter ball → 15 frames (0.25s) to next pretoss; setupPoint leaves P where he stands; FLICK in pretoss fires an instant quick serve (no toss, pow 0.62, aimed), plain TAP still does the toss ritual. 2. **Aim anywhere — verified.** Flick up targeted a far-side zombie (marker z=5.5 for a zombie at z=5, over the net), flick down a near-side one (z=18.1). If the lane system still fights Oliver, next step is true free-aim (flick → landing point, splash radius). 3. **Speed ramp — done.** G.splatT ticks in updateZombies (splatter only); spawnZombie multiplies speed by `1 + min(1.5, splatT/70)`. Verified 0.76 m/s fresh vs ~1.5 at splatT=140. Spawn interval / zMax do NOT ramp yet — add if Oliver wants more pressure. 4. **Eaten — done.** updateZombies: zombie within 0.7m of P (splatter only) → gore + 30 particles + splat(1) + voice('game_set_massacre') + mode 'eaten', ball dead, flash cleared. gameplay.update early-exits in 'eaten' (ticks idleT/eatenT, still updates zombies — they shamble on; baseline crossings no longer score). render: P not drawn, dark overlay EATEN. / games score / TAP TO PLAY AGAIN (restart prompt appears after G.eatenT > 0.7 so death-spam taps don't skip the screen). main.js: tap/key while eaten → location.reload(). ALSO: tennis volley fix (Oliver's follow-up "impossible to volley when the marker is behind me"): pre-bounce player contact widens reach x1.4 and swing window x1.8 (VOLLEY in config.js, applied in trySwing + the expiry in update). Root cause was no hard block, just a ~0.28s no-cue timing window at full ball pace; now ~0.5s. doHit also clears G.wasServe so volleying a serve back doesn't fault-check the return. CPU still returns 11/12 serves (benchmark holds). ## Verification recipe (the regression net — use it after the above) Drive the game deterministically via `window.T` with javascript_tool: the Browser-pane eval context reports `document.visibilityState==='hidden'`, so RAF is paused and `T.step(1/60)` gives clean manual stepping. `T.press('Space')` = tap. Dismiss the intro overlay first: `document.getElementById('intro')?.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true}))`. - Tennis regression: serve → CPU returns most balls → points/games advance. - Splatter: `T.G.mode` / force via 3 synthetic pointerdown on the umpire's projected position (see main.js listener; coords = proj(7.1,0,11.885), remember logical = cv.width/2 because RES=2). `T.zombies`, `T.spawnZombie(x, z, 'a')`, TUNE.zMax=0 to stop spawns during tests. - Prior benchmarks that must keep holding: CPU returns ~11/12 serves; assist gets the idle player in reach 8/8; constructed perfect corner (s=28, g×1.9) is a WINNER, medium center ball is returned (run scenarios ~12x — single runs are flaky due to 9% aiFlub). ## Tennis feel — CONFIRMED GOOD by Oliver (2026-07-11), don't regress - Volleys: pre-bounce contact gets reach x1.4 / window x1.8 (VOLLEY in config.js). - Half-volleys: a RISING ball is hittable at any height; only a dropping ball below CONTACT.minY is "past you" (trySwing). This was the big one — the assist parks you on the landing spot, so feet-bounces were unhittable before. - Miss diagnostics: every tennis swing logs to T.G.missLog (ring of 50); last miss reason flashes on the HUD (TUNE.missHud); TAPPING THE SCOREBOARD toggles an on-screen dump of the last 9 entries — Oliver screenshots that from his phone to report live situations. - Benchmarks that must keep holding: CPU returns 11/12 serves; 30 simulated points with human-like tapping (tap when incoming ball d<2.2–3.2) = 50 hits 0 whiffs; feet-bounce tap sweep ≈ 19/30 winning frames. ## POLISH ROUND — all 14 items of POLISH-PLAN.md are DONE (2026-07-27) New foundations you must know before touching anything: - **`G.modal`** (state.js) is the only thing that stops the world: `update()` early-returns on it after the 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 a `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 write `ctx.font =` by hand. Widest string checked: `SERVE FIRES: … TAP THE RACK TO SWITCH` at 259px of 270 portrait. - **Weapons are only ever WON.** `deliverTin()` has exactly one caller: `resolveInterlude('P')`. The old game schedule now calls the DUEL instead (`TUNE.duelFirst`/`duelEvery`, `G.duelDue`), fired from `proceedAfterClear()` — the one beat where the court is guaranteed empty. The beat it interrupts is parked in `G.afterDuel` and resumed when the duel resolves. `G.shameGame` is gone. - **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`. Flames are drawn procedurally (`drawFlames`), no sprite sheet. - **Sprite heights follow the PNG now.** `drawBoy` and the brute wound stages scale by `img.height / SPRITE_H`, because `_slice.py` common-scales a sheet: a deep mopping lean and a headless brute are genuinely shorter and were being stretched back to full height. - Benchmarks re-run and holding: CPU returns 23/24 serves; 30 human-tapped points = **50 hits / 0 whiffs** — but you MUST set `T.TUNE.autoAssist = 1.15` first, the Browser pane reports `pointer: fine` so the assist is off and every ball reads as "never reachable". - Brute is 5 hp: worst-case (max ramp) it still needs 22.5s to cross, vs ~7s of serves to kill. ## Feedback round on the polish (2026-07-27, same day) - **Fire never spread in real play.** The flame ball has full pierce and instant-kill, so it wiped its own lane and left nobody to catch. Fixed by `TUNE.burnBallR` — the ball ignites everything it FLIES PAST, every frame, whether or not it hits — plus wider `burnIgniteR` 4.5 / `burnSpread` 2.2. One flame serve into a realistic scattered 10-wave now kills 9, with 9 burning at once. **When testing fire, fire a real ARC**: a flat ball from z=16 hits the net at y≈0.83 (netH 0.914) and bounces back at you — the first "fire is broken" reading was my test hitting the tape. - **Wounded brutes need their own animation.** Every wound stage is a single frame and the brute now spends most of its life wounded, so it slid along like a decal. Rather than 8 more sprites they get a procedural lumber in `drawZombie` (translate + squash, no rotation, so pixels stay on the grid). Amplitudes MUST scale with `p.s` — at a fixed fraction the whole lurch was sub-pixel at range and read as nothing moving. - **The scoreboard "flicker" was the crowd.** The cheer bounce was `-Math.random()*2` per column per frame, i.e. TV static, showing through the scoreboard art's transparent ornate edges. Now a smooth per-column sine on `idleT`. Test for it: render twice WITHOUT stepping — identical pixels means nothing in the draw path is rolling dice per frame. - Ball boys: `sendBoy`'s `mop` now DEFAULTS to `G.mode === 'splatter'`. The running fetch-boy still exists, but only in tennis mode where there is nothing on the grass but a ball. `BOY_H` 1.2 → 1.5. - `TUNE.burnSpread` is a T-panel knob if Oliver wants fire hotter still. ## Second feedback round — "do it properly" (2026-07-27) Oliver rejected the procedural fakes on sight. The rule this round: **animation gets drawn frames.** - **Every brute wound stage now has a 2-frame lurch** (`z_gw`/`z_gwb`, `z_gw2`/`z_gw2b`, …). The procedural squash-and-sway that replaced them read as fake immediately — "that's where the comedy comes from". Generated by handing the generator a composite of the four A-frames (`assets/_sheet_gwound_a.png`, built with PIL) and asking for the stride swapped. **Gotcha:** the generator gave every B frame a dragged pole, but only stage 1's A frame has one — it would have blinked in and out each step. Stripped by keeping the largest connected blob (stages 2–3) and, where the pole touched the body (stage 4), by cutting on column density: the torso is dense, a dragged pole is a thin diagonal of ~7 px per column. - **Frames step off DISTANCE COVERED, never elapsed time** — `boy.dist` for ball boys, `zb.z / TUNE.bruteStride` for brutes. Time-driven cycles moonwalk whenever speed and frame rate disagree, which is exactly what "he slides faster than the animation suggests" was. Height for a wound stage ALWAYS comes from the A frame or the sprite pops size every step. - **One boy, one slot.** `sendBoy` no longer replaces a boy who is already out — that was the "appears and disappears": every kill teleported him back to the wing, so a kill streak restarted him several times a second. New work is appended to his round (`jobs`). One crossing is now 10.5s. - **The applauding crowd is a real two-frame animation** (`x_crowd` seated / `x_crowd2` arms up), swapped per column on its own phase. The previous "smooth wave" still shifted each column by a FRACTIONAL dy, and nearest-neighbour resampling of a scaled image at a fractional y re-snaps its pixel rows every frame — that boiling beside the HUD was the "scoreboard flicker". The board art is 99.9% opaque, so nothing was ever showing *through* it. **Test:** render twice without stepping — identical pixels means nothing in the draw path rolls dice; and with the crowd calm the frame-to-frame pixel delta must be 0.0%. - Editing a full-scene image (`x_crowd` → `x_crowd2`) preserved the layout far better than expected: mean difference 3.2/255, railing and tier rows unmoved. Worth reusing for other 2-frame swaps. ## Third feedback round (2026-07-27) - **THE FREEZE (serving dead, gun racket stuck over your head).** `G.mgT` only counted down inside `if (G.mgT > 0 && G.phase === 'rally')`. Anything that moved the phase mid-burst — a scheduled duel calling `setupPoint()` — stranded `mgT` above zero *forever*, and `mgT > 0` is one of `keyPressed`'s early returns, so no tap could ever serve again. The yellow blob Oliver saw was `drawMgRacket`'s muzzle flash still being drawn. Fixed at the root (the clock ticks in every phase; only the pellets are gated on 'rally') **and** semantically (`startDuel` cancels any burst — the duel is tennis). If another "can't serve" report ever comes in, check these flags first: `G.mgT`, `G.horde`, `G.changeover`, `G.interlude.introT` — each one blocks `keyPressed`. - **The mop boy has a job now** (`mopRound` in gameplay.js): walk to the biggest nearby stain, stand on it for `TUNE.boyMopDwell` while the push cycle animates in place, scrub it out, pick the next, then walk off. `TUNE.boyMaxJobs` caps the round so a kill streak can't employ him forever. Gore fades ~3.5× faster while he is actually stopped and mopping than when merely walking past. A straight-line constant-speed crossing was what read as moonwalking past the mess. - **Applause is floored at the audio length.** `assets/audio/tennis-applaus.wav` is 4.16s and always plays out in full, but every `cheer()` call passed a shorter beat, so the stand sat down while the clap was still audible. `cheer()` now does `Math.max(crowd.cheer, seconds, APPLAUSE_T)`. - **Regenerating a crowd frame: colour-match it or don't ship it.** The bolder re-roll came back much brighter and bluer; swapping to it would have flashed the whole stand. Fixed by a per-channel linear map onto frame one's mean and sd (means now agree to within half a level), then quantised to 64 colours. Verify with a mean-difference number before trusting any regenerated frame. - **Net and umpire were both wrong since forever.** `COURT.netW` was the singles-stick width 10.06 while the court is drawn to the doubles lines, so the net stopped 0.46 m short each side — now 10.97, flush. `UMP_X` 5.6 put the chair's near legs 0.45 m *inside* the doubles line (x_ump is 42×112 px at UMP_H 3.0 = 1.12 m wide as drawn); 6.05 stands them exactly on the line. Do not push past ~6.3 or the chair clips the right edge of the portrait viewport. The wider net is a real physics change (balls between |x| 5.03 and 5.485 now catch the tape); serve returns measured 134/150 = 89.3% ±2.5% afterwards, bar still inside one sigma. ## Fourth feedback round — feel polish (2026-07-27) - **The draw meter is a fixed bar across the bottom** (~90% of W, 10px tall, `METER_Y()` in render.js), not a 26×4 stub floating on the apron by the player's feet. The serve hint is suppressed while `G.aim` is live because they share that slot. `TUNE.pullFull` 95 → 130: at 95 every pull maxed out by accident and the meter meant nothing. Don't push much past ~150 — a pull that STARTS low on the screen still has to be completable before the finger runs off the glass. - **Aim magnet.** `flickTarget` used to take the nearest body *along* the lane, so a close shambler a little off-axis stole shots clearly aimed past it. It now picks the best-ALIGNED body (normalised offset within the cone), with `TUNE.aimTie` (0..1) of slack in which a nearer body still wins — so a straight-ahead lane still eats the nearest one first, which is the settled feel. `aimTie: 1` restores the old "nearest always wins" exactly, for A/B. - **Bomb**: radius 2.5 → 4.2 (2 kills → 5 in a scattered 10-wave), 120-particle eruption, a ring of thrown stains, size-4 pile. The white-out (`rgba(255,255,255,0.85)`) is now a brief warm bloom (`rgba(255,150,70,0.3)`) — the flash was hiding the very gore it announced. - **Rack touch target**: `rackH` 22 → 30 and `rackHit` pads the touch band by `RACK_GRAB` above and runs to the screen edge below. 22 logical px is ~30 CSS px on a phone, well under the ~44 a thumb expects; it's now ~62. Rack text is positioned proportionally (`g.h * 0.33` / `0.72`) so the tile height stays a free knob. ## Fifth feedback round (2026-07-27) - **Crowd brightness**: both crowd sheets got an identical gamma-0.8 lift (means 30/14/39 → 42/22/56) and the splatter tint dropped 0.5 → 0.26. **If you ever touch one crowd frame you must apply the exact same curve to the other**, or the clap swap starts flashing brightness. `assets/_sheet_crowd1.png` is the pre-lift original. - **The splatter serve hint led with the tap** ("TAP TO SERVE · PULL BACK TO AIM") when pulling is the entire gesture. Now "PULL BACK TO AIM · RELEASE TO FIRE", plus `drawPullHint()`: after `TUNE.pullHintAfter` seconds of sitting in the serve stance untouched (`G.idleHintT`), a ghost thumb slides back while chevrons grow up-court, on a loop. It teaches the inverse relationship the text can't. - **The mop boy has three sheets now**, not two: `x_boymop` is the push cycle used ONLY while stopped and working, `x_boycarry` is him walking upright with the mop shouldered, `x_boyrun` is the tennis-mode fetch. He was mopping the air the whole way to the stain. ## Gotchas learned the hard way - Browser-pane verification: python http.server sends no cache headers, so edited ES modules often load STALE even after location.reload(). Reliable fix: preview_stop + preview_start (a new port = new origin = cold cache). Always sanity-check a just-added field exists (e.g. `T.G.newField !== undefined`) before trusting test results. - Sprite pipeline: generate via generate-image skill (needs OPENROUTER_API_KEY in repo .env, gitignored; run the script with the scratchpad venv python — system python lacks requests) → magenta background → assets/_slice.py (COMMITTED now, no more scratchpad rebuilds: chroma-key on the corner color + magenta de-fringe, column-gap segmentation, --largest keeps only the main blob, common-scale per sheet, IDLE_TARGET_H=112). Generator sometimes ignores magenta (harmless — the key uses the corner color) or draws near-identical animation frames (re-prompt with strongly contrasted pose descriptions). If two frames touch (a dragged weapon bridging the gap), split the sheet at the midpoint and slice each half with --largest. Passing the existing sheet as --input (edit mode) nails the style. - drawChar mirrors sprites by ch.facing — any stance that must not mirror needs facing reset (setupPoint pins facing=1). - Standalone art (logo, popup panels) uses `assets/_key.py` instead of `_slice.py`: chroma-key + crop to the content bbox + optional downscale, with NO frame splitting and NO forced sprite height. `python3 assets/_key.py sheet.png out.png [target_width]`. - If a generated sheet's frames TOUCH (a mop handle bridging the gap), `_slice.py` finds 1 frame and there is no column of magenta to split on — re-prompt for "at least 150px of empty magenta between frames" and "the prop must stay inside its own frame". That worked; hand-splitting did not. - The scratchpad venv/scripts get wiped between sessions — recreate venv (requests+pillow) and slice.py lives only in scratchpad; its logic is described above if gone. - Don't trust single-sample gameplay sims; randomness (aiFlub 9%, aim error) needs ~12 runs (a 9/12 serve-return run is normal flub noise — re-run 24 before suspecting a regression). - Synthetic touch tests: input.js only sets stick.moved in touchmove, so a touchstart→touchend pair 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.55m radii at coarser steps and kills silently don't happen (looks exactly like a bug). - preview_start autoPort only yields a fresh origin if the configured port is BUSY — hold 8641 with a throwaway socket first, or you get stale cached modules again. - Any loop over zombies[] whose body can kill (killZombie/splatter) can cascade into sweepCourt and gut the array mid-iteration — keep the `if (!zb) continue` guards. - Commit style: imperative summary + Co-Authored-By Claude line; push = deploy.