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>
222 lines
16 KiB
Markdown
222 lines
16 KiB
Markdown
# 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:
|
||
```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 <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).
|