All eight issues from Oliver's 35-game retest. The freeze (1 and 6 — one root cause): the movement block, pose update included, lived inside `if (G.phase === 'rally')`, so every 'over' beat and every pretoss stranded the player mid-animation while the zombies walked on. It now also runs whenever splatter is in any phase but 'toss' — 'toss' is the aim, and the thumb is busy. Tennis keeps its old gating. The rest: - the mop boy is waved off the sudden-death duel and barred for its duration - triple-serve clones inherit the serve's topspin gravity, so the wings stop floating while the centre ball dives - "Time." belongs to the changeover's own countdown, not to whatever beat happened to expire while the breather ran - the tutorial illustration is a finger pressing and pulling back now, not a curved arrow that read as a scroll - aiming holds the pretoss stance; the smash plays when the shot fires - GAME N sits on the scoreboard permanently HANDOFF records all eight as closed with what the fix was, plus two ways a soak rig lies to you and why the CPU-return benchmark can't be reproduced from the number alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
18 KiB
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. All eight issues from Oliver's 35-game retest are fixed and verified in the browser; they are recorded at the bottom as CLOSED with what the fix was, because each one is a trap worth not re-digging. Awaiting Oliver's 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_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.
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 stashthe 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,
mgTback 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 duringG.clearingmakes the hand-clear un-completable and reads as a 30-minute stall; and reviving the player after eacheateninflates gore past its cap, because the death push (zombies.js, theeatenRhit) is the one uncappedgore.pushin the file — fine at one death per run, not at 260. Respectclearing, and either end the soak on death or setTUNE.eatenR = 0to make him untouchable. - 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.
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
Same root cause, one fix. The whole movement block — including P.pose = moving ? 'run' : 'idle' —
lived inside if (G.phase === 'rally'), so every 'over' beat (changeover, game beat, set card) and
every pretoss froze the player while updateZombies ran on. That pretoss case is issue 6:
endHorde calls setupPoint(), which hands back to pretoss.
The block now also runs whenever G.mode === 'splatter' && G.phase !== 'toss'. 'toss' stays frozen
on purpose — that is the aim, and the thumb is busy drawing the bow. Tennis is deliberately
untouched (verified: a keyboard-held ArrowLeft moves the player 0.00 m during a tennis 'over',
−2.70 m during a splatter one).
Oliver's other option — stopping the zombies for the breather — was NOT taken. startChangeover's
"it's calm, not safe" is still the design; being able to walk was the actual complaint.
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.