Files
splatter-tennis/HANDOFF.md
T
wuwiandClaude Fable 5 811fe3f91f Phase 10: sprite session — runner, brute, shield zombie, weapon tin
Sheets generated with the existing zombie sheet as the style anchor (edit mode);
sliced to z_f1/2 (runner sprint), z_g1/2 (brute + net-post club), z_s1/2/3
(shield zombie, chair up / chair dropped stagger), x_tin (black new-balls tin).
assets/_slice.py is now committed (chroma-key + de-fringe + segmentation) so the
pipeline survives scratchpad wipes; HANDOFF updated.

Sprites are NOT wired into render.js yet — Phase 11 does that after Oliver
approves the sheets (plan's design checkpoint).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 10:19:21 +02:00

8.7 KiB
Raw Blame History

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.

NEXT UP — PLAN.md (v2)

Phases 1–9 are SHIPPED (1–6: shot loop, fanfare, gore piles, bombs, hordes, racket zombie; 7: match structure — spawn director/changeovers/sets/permadeath; 8: bomb juice; 9: weapons on the new-balls rule). PLAN.md v2 continues with phase 10 (SPRITE SESSION — STOP for Oliver's approval), 11 (enemy roster), 12 (weapon ladder), 13 (tennis interlude). Design decisions at the top of PLAN.md are settled with Oliver — don't re-litigate. Phase by phase, push after each.

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.

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).
  • 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.
  • Commit style: imperative summary + Co-Authored-By Claude line; push = deploy.