Every animation in this round gets real frames. The procedural squash-and-sway on the wounded brute read as fake on sight, and that gait is where the comedy lives. - Each brute wound stage now has its own 2-frame lurch (z_gw/z_gwb, z_gw2/z_gw2b, ...). The generator gave every B frame a dragged pole that only stage 1 actually carries, so it would have blinked in and out each step — stripped by keeping the largest blob, and where the pole touched the body, by cutting on column density (a torso is dense, a dragged pole is a thin diagonal). - Frames step off DISTANCE COVERED, never elapsed time — boy.dist for ball boys, z/bruteStride for brutes. A time-driven cycle moonwalks the moment speed and frame rate disagree, which is exactly what "he slides faster than the animation suggests" was. Change a speed now and the legs keep up on their own. - One boy, one slot. sendBoy no longer replaces a boy already on court: that was the appearing and disappearing, since every kill teleported him back to the wing mid-stride. New mess is appended to his round instead. He now walks for 10.5s a crossing, and there is a lot of mopping to do. - The applauding crowd is a real two-frame animation (seated / arms up), swapped per column on its own phase. The old "smooth wave" still shifted each column by a FRACTIONAL dy, and nearest-neighbour resampling at a fractional y re-snaps a column's pixel rows every frame — that boiling beside the HUD is what read as the scoreboard flickering. The board art is 99.9% opaque; nothing was ever showing through it. With the crowd calm the frame delta is now 0.0%. Benchmarks: 136/150 serve returns (90.7% ±2.4%, the 11/12 bar is inside one sigma), 30 human-tapped points = 50 hits / 0 whiffs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
220 lines
17 KiB
Markdown
220 lines
17 KiB
Markdown
# HANDOFF — Splatter Tennis (for the next Claude session)
|
||
|
||
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.
|
||
|
||
## 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).
|
||
|
||
- **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.
|
||
|
||
## 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.
|
||
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.
|
||
|
||
## 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.
|
||
|
||
## 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,
|
||
pointerdown fires *before* touchstart, so a shared handler cleared the modal and let the
|
||
touchstart underneath serve. `G.seen.<key>` gates first-time-only modals.
|
||
- **`UI` in config.js** is the accent palette (hot/warm/pick/cream/dim/bad/ball). Every HUD colour
|
||
goes through it. Gore reds are deliberately NOT in it — the splatter stays loud.
|
||
- **`F(size, bold)` in render.js** builds every canvas font string. Bare `monospace` resolves to
|
||
Courier, a slab SERIF — that was the "generic serif" bug, not taste. Never write `ctx.font =` by
|
||
hand. Widest string checked: `SERVE FIRES: … TAP THE RACK TO SWITCH` at 259px of 270 portrait.
|
||
- **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.
|
||
- **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.
|
||
|
||
## 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.
|
||
|
||
## 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.
|
||
|
||
## 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.
|