Rewrote HANDOFF.md around what a next session actually needs: what the game is, how it flows, the invariants you have to know before touching anything, the settled feel decisions, the regression net with its real numbers, the asset pipeline with the generator's known failure modes, and the hard-won gotchas. Five rounds of chronological "feedback round" sections are folded into that structure — the history is in the git log and the memory file, not here. Deleted POLISH-PLAN.md: all fourteen items shipped. PLAN.md is left in place but marked historical at the top of HANDOFF. Corrected two stale facts carried in the old handoff: RES is 3 (not 2, which would have thrown off the umpire-cheat coordinates) and UMP_X is 6.05 (not 5.6). Added an OPEN ISSUES section with Oliver's eight findings from the 35-game retest, each with a diagnosis from reading the code rather than just the symptom. Two of them (player frozen between games, sprite freeze after the horde) look like one root cause: the movement and pose block sits inside `if (G.phase === 'rally')`, so every 'over' beat freezes the player mid-frame while the zombies walk on. The triple-serve lob is confirmed: the wing clones integrate at flat gravity while the real serve carries up to 2.3x from topspin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
16 KiB
HANDOFF — Splatter Tennis
This file is the source of truth. PLAN.md is historical (the original build plan, all
shipped); POLISH-PLAN.md is deleted — everything it specified is done and recorded below.
Also auto-loaded: ~/.claude/projects/.../memory/splatter-tennis-history.md for the long history.
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_startnamesplatter-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. Bumpmain.js?v=Nin index.html every time or phones keep the old build. The Dockerfile must also COPY any new top-level file (it currently shipsindex.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.modalis 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 — onlyidleTticks for the blink.showModal/dismissModalin gameplay.js;drawModalis the LAST thing render draws. Dismissal must swallow the gesture: input.jstouchstartreturns on it, keydown returns on it, and thepointerdowndismisser is registered only onpointer: 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.UIin 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. Baremonospaceresolves to Courier, a slab SERIF — that was the "generic serif" bug, not taste. Never hand-writectx.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 fromproceedAfterClear()— the one beat where the court is guaranteed empty. The game beat it interrupts is parked inG.afterDueland 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.mgTin particular must keep counting down in EVERY phase (it used to tick only duringrally, so a duel callingsetupPoint()stranded it above zero forever = permanent lock). - Animation frames step off DISTANCE COVERED, never elapsed time —
boy.distfor ball boys,zb.z / TUNE.bruteStridefor brutes. Time-driven cycles moonwalk the moment speed and frame rate disagree. Sprite heights follow the PNG (img.height / SPRITE_H) because_slice.pycommon-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 overzombies[]so it is safe inside a kill loop;burnTick()returns true when the fire killed the body and the caller MUSTcontinue. 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 theif (!zb) continueguards and iterate backwards. - The ball boy is ONE slot.
sendBoynever 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 viajobs.mopRoundwalks him to a stain, holds him there forTUNE.boyMopDwellwhile 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_crowdseated /x_crowd2arms up), per column on its own phase, with no vertical offset. A fractionaldyon 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.pngis 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 belowCONTACT.minYis past you. - Aim magnet:
flickTargetpicks the best-ALIGNED body, withTUNE.aimTieof slack in which a nearer one still wins, so a straight-ahead lane still eats the nearest body first.aimTie: 1restores the old "nearest along the lane always wins" for A/B. TUNE.fps60 — 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.15first — the Browser pane reportspointer: 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 = 0to 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,
mgTback 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
generate-imageskill (needsOPENROUTER_API_KEYin the gitignored repo.env; run with the scratchpad venv python — system python lacksrequests). Passing the existing sheet as--input(edit mode) nails the style and is how every matching sheet here was made.- 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".
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.--largestkeeps only the main blob.- Standalone art (logo, popup panels) uses
assets/_key.pyinstead: chroma-key + crop to the content bbox + optional downscale, no frame splitting, no forced height. - Register new names in
render.js'sSPRpreload 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.pyreports "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_startonly helps if the port actually changes, and holding 8641 with a throwaway socket did not work reliably. What DOES work:then reload the page. Always sanity-check a just-added field before trusting any result — and note the trap: ifawait 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'})));state.jsis stale butgameplay.jsis fresh, a newGfield materialises as NaN (undefined + dt), which reads exactly like a logic bug. drawCharmirrors sprites bych.facing— any stance that must not mirror needs facing reset.- Synthetic touch tests: input.js only sets
stick.movedin 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.
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).