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).
-529
View File
@@ -1,529 +0,0 @@
# Splatter Tennis — Polish & Bugfix Plan
Written 2026-07-27 for a fresh Claude session. Everything below was verified against the
code at commit `c8990fe`; every "cause" line is something I actually read, not a guess.
**Read first:** `HANDOFF.md` (architecture, benchmarks, hard-won gotchas). This plan assumes it.
## How to work in this repo
- Zero build. Plain ES modules served by `python3 -m http.server` via `.claude/launch.json`
(`preview_start` with `{name: "splatter-tennis"}`).
- `config.js` = all tuning (`TUNE` is live-editable in-game via the `T` panel, which
auto-lists every key — new keys appear for free). `state.js` = all mutable state on `G`.
`gameplay.js` = rules/physics/update. `render.js` = all drawing. `zombies.js` = the splatter
sim. `input.js` = keys + touch. `main.js` = boot + the `window.T` test surface.
- **Stale modules are the #1 time sink here.** After editing, bump `main.js?v=N` in
`index.html` AND navigate to a fresh page URL (`http://localhost:8641/?cb=<new>`). Then
sanity-check freshness before trusting any result: `T.TUNE.someNewKey !== undefined`.
Bumping `?v=` also guarantees Oliver's phone picks up the build.
- RAF pause state varies per session. Probe it; halt with
`window.requestAnimationFrame = () => 0` before deterministic `T.step` tests.
- Synthetic touch needs a `touchmove` between start and end or it reads as a tap. Contact
tests must step `1/60` (coarser steps tunnel).
- Verify each item with a runnable check before moving on. Screenshot the visual ones at
`resize_window` mobile (375×812) — this is a phone game.
## Order of work
Items 1–3 are **foundations** that later items depend on. Do them first, in order.
Items 4–13 are independent after that and can be done in any order.
---
# 1. FOUNDATION: the modal system
Five separate requirements ("popup, game only continues after tapping it away") need the
same thing, which does not exist yet. Build it once.
**What exists today:** `G.card` (`state.js:60`) + `drawCard()` (`render.js:326`) draw a
centred banner, but they are purely cosmetic — the game keeps running underneath on an
`overT` timer. There is no input-gated pause anywhere.
**Build:**
`state.js` — add to `G`:
```js
modal: null, // {title, body: [lines], icon, color, onDismiss} — blocks play until tapped
seen: {}, // one-shot markers so a modal only fires the first time this run
```
`gameplay.js` — add near the card helpers:
```js
export function showModal(m) { if (!G.modal) G.modal = m; }
export function dismissModal() {
const m = G.modal; if (!m) return false;
G.modal = null; if (m.onDismiss) m.onDismiss();
return true; // tells the caller the gesture was consumed
}
```
`gameplay.js update()` — freeze at the very top, after the flash/card decay so nothing
expires while the player reads:
```js
if (G.modal) { G.idleT += dt; return; } // everything else waits
```
Put this AFTER the `hitstop` and `eaten` early-returns and AFTER `G.flash`/`G.card` decay,
but BEFORE the movement block. Rationale: the world must be completely frozen (no zombie
steps, no ball flight, no spawn timers) or a player reading a popup gets eaten.
`input.js` — dismissal must take priority over everything and swallow the gesture:
- In `touchstart`, first line inside the `for` loop: `if (dismissModal()) return;`
- In the `keydown` handler: any key dismisses.
- Guard `canAimServe()` with `!G.modal`.
`main.js` — `maybeRestart` must not fire through a modal.
`render.js` — a `drawModal()` drawn LAST (after the cards, before nothing). Spec:
- Full-screen scrim `rgba(8,6,10,0.82)`.
- Optional icon sprite centred above the title (used by the weapon popup).
- Title in the UI accent colour, body lines below, `TAP TO CONTINUE` blinking at the bottom.
- Use the pixel font (see item 3) — this is new text, so do not introduce more `monospace`.
**Acceptance:** with a modal open, `T.step(1)` × 60 must leave `zombies[0].z`, `ball.z` and
`G.splatT` unchanged; a synthetic `touchstart` clears `G.modal` and does NOT serve.
---
# 2. FOUNDATION: UI colour palette
**Requirement:** "Farbpalette etwas absoften (sehr starke rot und gelb töne in schrift und
interaktiven elementen)".
**Cause:** the hot colours are hard-coded as hex literals scattered through `render.js`.
`grep -o "#[0-9a-fA-F]\{6\}" render.js | sort | uniq -c` gives the inventory. The offenders:
| current | where | proposed softer |
|---|---|---|
| `#f8e048` | aim reticle, hints, NEW BEST, TAP TO PLAY AGAIN (5×) | `#e0cc72` |
| `#ff5050` | locked-target reticle, out-marker, SUDDEN DEATH card (3×) | `#d9705f` |
| `#e6ee3a` | selected rack slot, SET card (4×) | `#cbd06a` |
| `#e8f03a` | the ball itself (2×) | `#dbe063` |
| `#c02020` | GAME SET MASSACRE | `#a34a42` |
| `#e0231a` / `#eef23a` | intro logo CSS in `index.html` | see item 10 (logo art replaces it) |
**Do NOT** soften the gore reds (`PAL`/blood particles) — the splatter should stay loud;
only text and interactive UI were named.
**Build:** add a `UI` export to `config.js` next to `TUNE` (a plain object, not inside
`TUNE` — the T panel only renders numbers):
```js
// UI accent colours, one place so the whole HUD can be re-tuned at once. Deliberately
// desaturated from the original arcade primaries: they were shouting over the artwork.
export const UI = { hot: '#d9705f', warm: '#e0cc72', pick: '#cbd06a', cream: '#e8e8dc',
dim: '#8fa0b8', bad: '#a34a42', ball: '#dbe063' };
```
Then replace the literals in `render.js`. Keep one commit for the mechanical swap so a
revert is trivial.
**Acceptance:** screenshot before/after at mobile size; no remaining `#f8e048` / `#ff5050`
/ `#e6ee3a` / `#c02020` in `render.js`.
---
# 3. FOUNDATION: the serif font bug
**Requirement:** "Die Serifen-Schrift ist nicht schön. Die sieht so generisch aus."
**Cause — this is a real bug, not taste.** `index.html` bundles Silkscreen and `main.js`
warms it, but only 4 of 15 canvas font strings actually ask for it. Everything else says
`bold Npx monospace`, which resolves to Courier — a slab **serif**. Confirmed:
`grep -n "ctx.font = " render.js` → lines 329, 330, 560, 562, 566, 569, 572, 575, 588, 597,
603 all use bare `monospace`. Lines 314, 316, 534 correctly use `Silkscreen`.
**Build:** `render.js:534` already has the helper —
`const F = (sz, b = 1) => \`${b ? 700 : 400} ${sz}px Silkscreen, monospace\`;`
Hoist it to module scope (it currently lives inside the scoreboard block) and route **every**
`ctx.font =` through it. Silkscreen has no lowercase-friendly metrics at odd sizes; keep the
existing px sizes but expect slightly different widths — re-check the centred strings
(`GAME, SET, MASSACRE.` at 16px is the widest) still fit at 270 logical px wide (portrait).
Also fix the `#hint` line in `index.html` (`font: 12px monospace`) — same serif, and it is
visible under the canvas on desktop.
**Acceptance:** `grep -c "px monospace" render.js` returns 0 except inside `F`'s fallback
chain. Screenshot the eaten screen and a card in portrait; no serifs, nothing clipped.
---
# 4. BUG: weapon stock caps at 3
**Requirement:** "es können pro waffe immer nur drei gehalten werden. es sollten mehr waffen
angespart werden können."
**Cause:** `config.js:68` `wCap: 3` and `gameplay.js:336`
`const add = k => Math.min(TUNE.wCap, k);` inside `deliverTin()`.
**Build:** raise `wCap` to `9`. Check the rack renderer handles two-digit counts:
`render.js` draws `'×' + n` in the slot at 6px — at 270px wide with 5 slots each slot is
~50px, so `×9` is fine but verify `×12` if you go higher. Recommend 9 and stop.
While here: `deliverTin()` gives `TUNE.bombMax` (3) bombs but hard-codes `3` triples and
`3` flames and `1` gun. Pull those into `TUNE` (`tinTriples`, `tinFlames`, `tinGuns`) so the
economy is tunable from the panel like everything else.
**Acceptance:** `T.deliverTin()` four times in a row, then check
`T.G.bombs > 3`. Rack renders the count without overflowing its slot.
---
# 5. BUG: weapons arrive whether or not you win the duel
**Requirement:** "die waffen bekommt man unabhängig ob man das zombie 1:1 match gewonnen hat
oder nicht."
**Cause — there are two independent delivery paths and only one is gated on the duel:**
1. `resolveInterlude('P')` → `deliverTin()` (`gameplay.js:355`) — the reward for winning.
2. `splatPoint()` sets `G.tinDue` on a game schedule (`ballChangeFirst`/`ballChangeEvery`),
and `startChangeover()` (`gameplay.js:310`) calls `deliverTin()` when `tinDue` — with no
reference to any duel. This is the leak.
Note path 2 has a partial gate already: `G.shameGame` withholds the tin if *they* took a
game since the last delivery. That is not the same as winning the duel.
**The design decision (Oliver, 2026-07-27):** duels must come round on their own after a
while, "damit es immer die chance auf waffen gibt". So the schedule is not the problem — it
is attached to the wrong event. **Move it from delivering tins to calling duels.** The
cadence stays, the reward becomes something you have to earn, and the umpire-restores-order
framing is unchanged.
**Why the duel is rare today:** the trigger at `gameplay.js:~760` requires
`zombies.length === 1 && zombies[0].variant === 'r' && !zombies[0].dueled` — the court has to
be completely empty apart from one never-yet-dueled racket zombie. Racket zombies only appear
when you win a game (`splatPoint`) or survive a horde (`endHorde`), and fresh shamblers spawn
again the moment a clear finishes. So it fires by luck.
**Build:**
- Rename the schedule to what it now schedules: `TUNE.ballChangeFirst`/`ballChangeEvery` →
`duelFirst`/`duelEvery`, `G.tinDue` → `G.duelDue`. Keep the same arithmetic in
`splatPoint()`. Suggested start: first duel after game 2, then every 3.
- Delete the `deliverTin()` call in `startChangeover()` (keep the "CHANGEOVER" flash).
`G.shameGame` — which withheld a tin if they took a game — has nothing left to gate;
remove it rather than leaving vestigial state.
- Keep `resolveInterlude('P') → deliverTin()` as the only weapon source.
- **Fire the scheduled duel off the existing clear beat.** `proceedAfterClear()`
(`gameplay.js:290`) already runs at the one moment the court is guaranteed empty — the
player just cleared the wave by hand, and no new shamblers have spawned yet. That is the
natural slot. There: if `G.duelDue`, consume it, ensure a racket zombie exists
(`spawnRacketZombie()` if none, and clear `dueled` on the existing one so a veteran can be
challenged again), and start the interlude instead of the changeover/game beat. The
changeover follows after the duel resolves.
- Leave the existing opportunistic trigger in place. The schedule is a floor, not a ceiling —
a lucky clear can still produce a bonus duel, which is pure upside for weapon supply.
**Why this slot and not a timer:** clearing the wave is already mandatory before play
advances (`G.clearing`, a settled feel decision Oliver was emphatic about), so the schedule
can never stall. A wall-clock timer would need its own "wait for a safe moment" logic and
could fire mid-swarm.
**Acceptance:**
- Simulate ~10 games. A duel must occur at game 2, 5, 8 … regardless of how the waves fall.
- Lose a duel → `G.bombs`/`triples`/`flames`/`guns` unchanged, and the next duel still
arrives on schedule.
- Win one → exactly one tin, and the item-8 modal names it.
- No path reaches `deliverTin()` except `resolveInterlude('P')`
(`grep -n "deliverTin" *.js` should show the definition, that one call, and the `window.T`
test export — nothing else).
---
# 6. POPUP: entering splatter mode from tennis
**Requirement:** popup with an **illustration** explaining the new mechanic, stays until
tapped.
**Trigger:** `gameplay.js:410`, inside `startTransition()`'s timeline —
`once('go', 8.2, () => { G.mode = 'splatter'; ... })`. Fire the modal there, after
`setupPoint()`.
**Content:**
- Title: `THE MATCH HAS CHANGED`
- Body: pull back to aim · the further you pull, the harder it hits · a weak shot only
staggers them
- Icon: a generated illustration (see Appendix A). Ask for a pixel-art panel showing the
player pulling the slingshot aim back with a chevron trail toward a shambler — it should
teach the gesture, not just decorate.
Because this is the mechanic tutorial, it is worth the biggest illustration of the set.
Consider a wider modal layout for this one (`icon` sized ~120×80 logical px).
**Acceptance:** enter splatter; the world is frozen and no zombie moves until tapped.
---
# 7. POPUP: entering the 1:1 duel (first time)
**Requirement:** popup saying it is a normal tennis game again and that winning earns a
weapon. "momentan gibt es schon ein popup aber auf dem steht auch anderer unnötiger kram."
**Cause:** `render.js:593` draws
`drawCard('SUDDEN DEATH', 'WIN THE POINT · THE LINES ARE LIVE', 'THE POINT WINS YOU A NEW WEAPON', '#ff5050')`
on a 2.4s timer (`G.interlude.introT`, set at `gameplay.js:764`). It is not dismissible, it
disappears on its own, and "THE LINES ARE LIVE" is the unnecessary cruft.
**Build:** replace the timed card with a real modal, gated on `G.seen.duel`:
- Title: `SUDDEN DEATH`
- Body: `IT'S TENNIS AGAIN — ONE POINT` / `WIN IT AND THEY HAND YOU A WEAPON`
- On dismiss: start the point.
- Delete the `introT` card path in `render.js` and the `introT` countdown in `update()` if
the modal fully replaces the beat. Keep `G.interlude.introT` only if the umpire voice line
still needs the pause — check `voice('sudden_death', 2.4)` timing at `gameplay.js:~766`.
- Subsequent duels in the same run: no modal, keep a short flash.
**Acceptance:** first duel opens a modal that blocks serving; second duel does not.
---
# 8. POPUP: weapon received
**Requirement:** shows and describes the weapon; game continues only after tapping.
**Trigger:** inside `deliverTin()` (`gameplay.js:335`), replacing the
`G.flash = { text: 'NEW BALLS: ' + SLOTS[w+1] }` line.
**Content:** icon = the existing rack sprite (`w_bomb` / `w_triple` / `w_fire` / `w_gun` —
already loaded in `render.js`'s `SPR`), title = the weapon name, one line of description:
| slot | name | line |
|---|---|---|
| 1 | BOMB | detonates on contact — clears a crowd |
| 2 | TRIPLE | one serve, three balls |
| 3 | FIRE | kills on any touch and **sets the others alight** (item 12) |
| 4 | GUN | hold the line and spray — a burst, not a serve |
**Careful:** `deliverTin()` is also called from `resolveInterlude()` which is itself called
from `killZombie()` mid-iteration over `zombies[]` (`zombies.js:51`). Opening a modal there
is safe (it only sets state) but do NOT make `showModal` do anything that touches the
zombie array.
**Acceptance:** win a duel → modal with the right icon; play is frozen until tapped.
---
# 9. POPUP: the horde after a won set
**Requirement:** popup explaining you now have to shoot with the gun; blocks until tapped.
**Trigger:** `proceedAfterClear()` sets `G.pendingHorde` and shows the SET card
(`gameplay.js:290-302`); the horde starts when the `overT` beat expires (in `update()`'s
`over` branch → `startHorde()`).
**Build:** keep the SET card (it is the milestone beat and Oliver asked for it originally —
see the memory note on milestone cards), then show the modal instead of auto-starting:
- Title: `THEY KEEP COMING`
- Body: `THE RACKET FIRES ON ITS OWN` / `SLIDE TO AIM · HOLD THE BASELINE`
- On dismiss: `startHorde()`.
Note `startHorde()` already pins `P.z` to the baseline and switches input to absolute
finger-steering (`touchPX()`), which is a genuinely different control mode — that is exactly
why the popup is warranted.
**Acceptance:** win a set → SET card → modal → horde only begins after the tap.
---
# 10. Start-screen logo
**Requirement:** "ein schöneres Splatter Tennis Logo für den Startscreen generieren."
**Cause:** the current logo is CSS text (`index.html`, `#intro .l1` / `.l2`) — a hard red
`#e0231a` over hard yellow `#eef23a` with stacked text-shadows. Generic.
**Build:** generate a pixel-art logo PNG (Appendix A), drop it at `assets/logo.png`, replace
the two `<span>`s with `<img>`. Keep the `drop` animation and the blinking `TAP TO START`.
Prompt direction: bold pixel lettering, tennis-ball yellow-green and dried-blood red, a
dripping-gore underline, transparent background, roughly 640×280.
Soften the surrounding CSS colours to match item 2 while you are in there.
**Acceptance:** screenshot the intro at mobile and desktop; the logo must not overflow
`max-width: 92vw` and must stay crisp (`image-rendering: pixelated` on the `img`).
---
# 11. The brute takes 5 hits, with more wound states
**Requirement:** big zombie harder to kill (5 hits), correspondingly more wound states,
sprites to be generated.
**Cause:** `config.js:80` `bruteHp: 2`. `render.js:160` has exactly one wound skin:
`if (zb.variant === 'g' && zb.hp < TUNE.bruteHp && SPR.z_gw) name = 'z_gw';`
**Build:**
- `bruteHp: 5`.
- Generate `z_gw2`, `z_gw3`, `z_gw4` (Appendix A) as a progression: 4/5 hp = one arm torn
off (that is today's `z_gw`), 3/5 = both arms, 2/5 = jaw/half the head gone, 1/5 = barely
standing, dragging itself. Register them in `render.js`'s `SPR` preload list (line ~75).
- Select by hp:
```js
if (zb.variant === 'g' && zb.hp < TUNE.bruteHp)
name = ['z_gw4', 'z_gw3', 'z_gw2', 'z_gw'][Math.max(0, zb.hp - 1)] || 'z_gw';
```
(guard each with an `SPR[...]` existence check like the current code does, so a missing
asset degrades to the walk frame instead of drawing nothing.)
- `bleed()` (`zombies.js:158`) picks wound anchors per variant — extend the brute branch so
later stages bleed from more points. Keep it sparse; the settled feel note is explicit
that blood must stay thin (see `HANDOFF.md` and the feel memory).
**Balance check:** the brute already soaks machine-gun pellets one per hit
(`zombies.js` pellet loop). At 5 hp it costs 5 pellets — fine during a horde, but verify a
brute arriving during normal play is still killable with the ~1.4s serve cycle before it
reaches the baseline. Measure `time-to-kill vs time-to-baseline`; if it is unwinnable, say
so rather than quietly lowering the hp Oliver asked for.
---
# 12. Fire spreads
**Requirement:** the fireball kills but **ignites** surrounding zombies; a burning zombie
that walks near another sets that one alight too; needs a dynamic fire animation.
**Cause:** today `ball.flame` (`zombies.js:245`) is instant-kill-on-touch with full pierce,
and nothing else. The only fire visual is the ball's ember trail
(`gameplay.js` pushes `parts` with `fire: true`; drawn at `render.js:496`).
**Build — new state on the zombie:**
```js
zb.burnT // seconds of burning left; undefined = not on fire
```
`config.js`:
```js
burnTime: 3.0, // s a zombie burns before it collapses
burnDps: 1.0, // hp lost per second while burning (so burnTime*burnDps must exceed hp)
burnSpread: 1.3, // m — a burning body ignites anything this close
burnIgniteR: 2.6, // m — the flame ball's initial ignition radius around the kill
```
`zombies.js`:
- On a flame-ball contact: kill the struck zombie as now, then ignite every zombie within
`burnIgniteR` (`zb.burnT = TUNE.burnTime`).
- In `updateZombies`'s per-zombie loop: if `burnT > 0`, decrement it, apply `burnDps * dt`
damage (kill via the existing `killZombie` path so gore/scoring/ball-boy all still fire),
emit 1–2 `fire: true` particles per frame from the body, and ignite any un-burning zombie
within `burnSpread`.
- **Chain-reaction hazard:** `killZombie` splices the array and `splatPoint` can cascade.
The existing loops guard with `if (!zb) continue;` — the ignition pass must do the same and
must iterate backwards. `HANDOFF.md` calls this out explicitly; a fire chain is the worst
case for it, so write the loop defensively and test with 15 zombies packed together.
- Burning zombies should walk **faster** briefly (panic) — a nice touch and it makes fire a
double-edged weapon. Optional; flag it to Oliver.
`render.js` — the dynamic animation:
- Draw flames on a burning body procedurally rather than as sprites: 3–5 vertically-stretched
flame quads per zombie, offsets driven by `Math.sin(G.idleT * f + zb.walkT)` so each body
flickers on its own phase, colours `#ffc030` → `#ff8010` → dark smoke as `burnT` runs out.
This is cheaper than a sprite sheet and reads better at 4 different burn stages.
- Tint the zombie sprite toward orange while burning (`ctx.globalAlpha` + a second
additive-ish pass, or just draw the sprite then a semi-transparent orange rect clipped to
it — keep it simple at this resolution).
**Acceptance:** a flame serve into a line of 6 packed shamblers ignites the neighbours, the
fire visibly chains outward, and all 6 die within `burnTime`. No exception thrown (this is
the loop-mutation trap). Frame time stays sane with 14 burning bodies.
---
# 13. Death animation — the player becomes gore
**Requirement:** "Wenn man gefressen wird braucht es eine Endanimation, die zeigt dass man
selbst zu gore wird."
**Cause:** `zombies.js:229` flips `G.mode = 'eaten'` and `render.js:506` simply stops
drawing the player (`if (G.mode !== 'eaten') drawChar(P)`). There is already a small burst
(30 particles + one gore decal) at the eat site, but the summary screen appears immediately
on top of it.
**Build:**
- `G.eatenT` already counts up from 0 (`state.js`). Use it as the animation clock.
- Phase A (0 → ~0.5s): keep drawing the player, but jerking — a violent shake offset and a
reddening tint, with the zombies crowding in. Kill the ball, freeze the score.
- Phase B (~0.5 → 1.4s): the sprite is replaced by an expanding gore burst — reuse
`killZombie`'s machinery (it is the established look): a big `gore` stain, a size-4 `piles`
entry at `P.x/P.z`, and ~90 particles with a wide `vy` spread. Add `G.shake` and one
`boomFlash` frame for the impact.
- Phase C (from ~1.6s): the existing summary fades in (`render.js:559`). Gate
`TAP TO PLAY AGAIN` on `G.eatenT > 2.2` instead of `0.7` so the animation is not skipped
by a panic tap.
- The pile stays visible under the summary scrim — that is the point: you are the biggest
gore mound on the court.
**Acceptance:** get eaten; screenshot at eatenT ≈ 0.3 / 0.9 / 2.0 and confirm three distinct
stages. Restart still works.
---
# 14. Longer changeover, and the ball boys get a mop
**Requirement:** "changeover sollte etwas länger dauern. die balljungen, die die reinigung
machen brauchen einen mop."
**Cause:** `config.js:33` `changeoverT: 4.5`. `drawBoy()` (`render.js:136`) uses
`x_boyrun1..4` — a plain 4-frame run cycle, no prop.
**Build:**
- `changeoverT: 7.0`. The changeover already suppresses spawns and serving
(`zombies.js` spawn gate, `keyPressed` guard), so a longer one is a real breather. Check
it does not feel dead — if it does, have more boys cross.
- Generate `x_boymop1..4` (Appendix A): the same boy, bent over, pushing a mop, 4 frames of
a push cycle.
- `drawBoy()` picks the sheet by intent. Today `sendBoy()` is used for BOTH "fetch the dead
ball" and "mop this splat" (`state.js:95`, called from `killZombie` with the corpse
position). Add a flag: `sendBoy(tx, tz, mop = false)`, set `mop: true` from `killZombie`
and from `startChangeover`, and select `x_boymop*` vs `x_boyrun*` accordingly. The mopping
boy should also move slower — mopping is not sprinting.
- The gore-cleaning already happens in `updateZombies`'s boy pass (`d.a -= dt * 1.6`); with a
mop sprite that finally reads as *why* the stain fades.
**Acceptance:** trigger a changeover; the boy crosses with the mop sprite, stains visibly
shrink behind him, and the breather runs 7s.
---
# Appendix A — generating sprites and illustrations
The pipeline is documented in `HANDOFF.md` under "Gotchas learned the hard way"; the short
version:
1. Use the `generate-image` skill. It needs `OPENROUTER_API_KEY`, which is already in the
repo's gitignored `.env`. Run generation scripts with a scratchpad venv python
(system python lacks `requests`); the venv is wiped between sessions, so recreate it with
`requests` + `pillow`.
2. Ask for a **magenta background** and, for multi-frame sheets, frames in a row separated by
clear gaps.
3. Slice with the committed `assets/_slice.py`:
`python3 assets/_slice.py sheet.png out1.png out2.png ...` (add `--largest` when frames
touch and stray fragments bridge the gap).
4. Register new names in `render.js`'s `SPR` preload array (~line 71).
Known generator quirks (all from `HANDOFF.md`, all still true):
- It sometimes ignores the magenta instruction — harmless, the key uses the corner colour.
- It often draws near-identical animation frames. Re-prompt with strongly contrasted pose
descriptions.
- Passing the existing sheet as `--input` (edit mode) is the reliable way to match style.
**Do this for every asset here** — `_sheet_brute.png` for the brute wounds,
`_sheet_boyrun.png` for the mop boy — the style must match what is already on court.
Assets this plan needs:
| file(s) | source sheet to match | item |
|---|---|---|
| `z_gw2`, `z_gw3`, `z_gw4` | `_sheet_brute.png` / `_sheet_gwound.png` | 11 |
| `x_boymop1..4` | `_sheet_boyrun.png` | 14 |
| `assets/logo.png` | — (new, standalone) | 10 |
| transition tutorial illustration | — (new, standalone) | 6 |
The two popup illustrations and the logo are **not** chroma-keyed sprites — generate them
with transparent or dark backgrounds sized for their use and skip `_slice.py`.
---
# Appendix B — things to check before declaring done
- `HANDOFF.md`'s benchmarks must still hold: CPU returns 11/12 serves; 30 simulated points
with human-like tapping = 50 hits / 0 whiffs. Items 2, 3 and 11 can all disturb these.
- The settled feel decisions are recorded and must not be regressed silently: the two-stage
serve, the manual wave clear, thin dynamic blood, one-gesture-means-one-thing on touch.
If any item here would break one, flag it to Oliver instead of deciding.
- Update `HANDOFF.md` with anything new that bit you.
- Bump `main.js?v=N` in the final commit so phones get the build.