Files
splatter-tennis/HANDOFF.md
T
wuwiandClaude Opus 5 8dca9b6d45 Delete PLAN.md — HANDOFF is the only planning document
Everything it specified shipped long ago, and the parts still worth knowing are
already folded into HANDOFF. Keeping a stale plan around only invites a future
session to treat it as live spec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 09:02:51 +02:00

16 KiB
Raw Blame History

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.<key> 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:
    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 <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).