Files
splatter-tennis/HANDOFF.md
T
wuwiandClaude Fable 5 e7aab295d4 WIP splatter feedback round + HANDOFF.md for session continuation
Done (unverified): faster serve cadence in splatter (overT 0.25, serve from
where you stand, flick = instant aimed quick serve), aim-anywhere targeting
relative to the player. Not done: zombie speed ramp (G.splatT exists but is
never incremented), eaten-on-touch death + restart screen. Full state and
plans in HANDOFF.md.

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

7.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.

ACTIVE TASK — Oliver's last message (his exact words):

much better!!! the time between each serve is too long in zombie mode. right now it auto aims to the closest. i should also be able to aim anywhere includibg over the net. the speed of zombies should increase over time when they reach me i should be eaten

Status per item (working tree runs clean, no console errors, but items 1–2 are UNVERIFIED):

  1. Serve cadence — code DONE, not verified.

    • gameplay.js dead-ball branch: splatter overT 0.7 → 0.25, new_balls voice freq 0.2 → 0.12.
    • state.js setupPoint splatter branch no longer repositions P (serve from where you stand).
    • keyPressed pretoss+splatter: a FLICK now fires an instant quick serve (no toss, quality 0.62, aimed by the flick via hitServe(P, 0.62, flick)); a plain TAP still does the toss ritual (perfect serves remain possible). hitServe gained an aim param.
  2. Aim anywhere incl. over the net — code DONE, not verified.

    • zombieTarget(aim) in gameplay.js rewritten: no aim → most advanced zombie (max z); with aim → lane relative to the PLAYER (P.x + ax*4), az<0 prefers far zombies, az>0 prefers close ones on your own side. All z candidates included.
    • NOTE Oliver's complaint was that it "auto aims to the closest" — if the rewrite still feels wrong, the next step is true free-aim: flick direction → landing point directly (no zombie snap), with a generous splash/hit radius. Ask him which feel he wants if the lane system still fights him.
  3. Zombie speed ramps over time — NOT DONE.

    • G.splatT field was added to state.js but is NEVER incremented — wire it up in updateZombies (only while G.mode==='splatter').
    • Planned approach (my edit failed on a stale string, zombies.js current content is in this repo, read it): in spawnZombie, multiply speed by 1 + Math.min(1.5, G.splatT / 70) (ramp at spawn time keeps the shot-lead math exact), and optionally shrink the spawn interval / grow zMax with splatT.
  4. Eaten when they reach you — NOT DONE. Plan agreed with Oliver:

    • In updateZombies: G.mode==='splatter' && Math.hypot(zb.x-P.x, zb.z-P.z) < 0.7 → eat: gore decal + big particle burst at P, splat(1), voice('game_set_massacre', 0.8), G.mode='eaten', ball.active=false. Zombies keep shambling for ambience (guard the eat check to mode==='splatter' so it fires once).
    • gameplay.update: early-exit branch for mode 'eaten' (still tick idleT + updateZombies).
    • render.js: skip drawChar(P) when eaten; dark overlay with 'EATEN.', final games score, 'TAP TO PLAY AGAIN'. Also gate the serve prompt on mode!=='eaten'.
    • main.js: in the cv pointerdown listener (and a keydown), if mode==='eaten' → location.reload() (laziest honest restart; fine for now).
    • Existing baseline-cross rule (splatPoint('O') at z >= L+0.7) stays: crossing without touching you is a point for THEM, touching you is death.

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).

Gotchas learned the hard way

  • Sprite pipeline: generate via generate-image skill (needs OPENROUTER_API_KEY in repo .env, gitignored) → magenta background → scratchpad slice.py (chroma-key, segment, strip ground lines/dividers, common-scale per sheet, IDLE_TARGET_H=112). Generator sometimes ignores magenta (then: edit-mode the image back to magenta) or draws near-identical animation frames (then: re-prompt with strongly contrasted pose descriptions).
  • 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.