HANDOFF is the source of truth; record the 35-game retest findings

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>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-07-30 09:00:49 +02:00
1 parent 7dde5874ab
commit 01ee068e6d
2 files changed
+198 -788

No files matched your search

+198 -259
View File
@@ -1,281 +1,220 @@
# HANDOFF — Splatter Tennis (for the next Claude session)
# HANDOFF — Splatter Tennis
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.
**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 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).
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
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.
- **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 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.
`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.
## 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.
## 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.
## 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.
## Architecture & invariants — know these before touching anything
## 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,
- **`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
- **`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.
- **`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 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.
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`.
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.
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.
## 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.
## 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.
## 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.
## 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`.
## 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.
**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.
## 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.
## 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.
## 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.
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
- 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.
- **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).