The three foundations first, since everything else leans on them:
- G.modal is the only thing that stops the world. update() early-returns on it
so no zombie steps, no ball flight, no spawn timers run while a popup is on
screen — you can read it without being eaten. Dismissal swallows the gesture;
the pointerdown dismisser is registered only on pointer:fine, because on touch
pointerdown beats touchstart and a shared handler cleared the modal and then
served with the same thumb.
- UI in config.js is the accent palette. The arcade primaries were shouting over
the artwork; the gore reds deliberately stay loud.
- Every canvas font string now goes through F(). Bare `monospace` resolved to
Courier — a slab serif — which was the "generic serif" bug, not taste.
Then: weapons are only ever WON. deliverTin() has exactly one caller now
(resolveInterlude('P')); the old game schedule calls the DUEL instead, fired off
proceedAfterClear() where the court is guaranteed empty. Four popups (splatter
tutorial with a generated illustration, first duel, weapon received, horde),
wCap 9 with the tin amounts tunable, a 5-hp brute that comes apart over four
wound skins, fire that ignites the neighbours and chains outward, a three-stage
death where you become the biggest gore mound on the court, and a 7s changeover
with a ball boy who finally has a mop.
New art: assets/logo.png, x_howto, z_gw2-4, x_boymop1-4, plus assets/_key.py for
standalone art (chroma-key + crop, no frame splitting, no forced sprite height).
Benchmarks re-run and holding: CPU returns 23/24 serves, 30 human-tapped points
= 50 hits / 0 whiffs, 26 simulated games with no exceptions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
14 KiB
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_startnamesplatter-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 viaG.transT): applause cut, palette lerp (PAL in render.js), umpire "Quiet… please" at low playbackRate (voice() has arateparam), CPU opponent swapped for zombie-CPU (variant 'c'), scripted first spawns, thenG.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):
- 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.
- 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).
- 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. - 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 — onlyidleTticks (for the blink).showModal/dismissModalin gameplay.js;drawModalis the LAST thing render() draws. Dismissal must SWALLOW the gesture: input.jstouchstartreturns on it, keydown returns on it, and apointerdowndismisser is registered only onpointer: 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.UIin 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. Baremonospaceresolves to Courier, a slab SERIF — that was the "generic serif" bug, not taste. Never writectx.font =by hand. Widest string checked:SERVE FIRES: … TAP THE RACK TO SWITCHat 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 fromproceedAfterClear()— the one beat where the court is guaranteed empty. The beat it interrupts is parked inG.afterDueland resumed when the duel resolves.G.shameGameis gone. - Fire chains.
zb.burnT/burnD;ignite()is read-only overzombies[]so it is safe inside a kill loop;burnTick()returns true when the fire killed the body and the caller MUSTcontinue. Flames are drawn procedurally (drawFlames), no sprite sheet. - Sprite heights follow the PNG now.
drawBoyand the brute wound stages scale byimg.height / SPRITE_H, because_slice.pycommon-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.15first, the Browser pane reportspointer: fineso 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.
TUNE.burnSpread1.3 m chains reliably through shoulder-to-shoulder shamblers (~1.0 m apart); at 1.2 m spacing the tail of a line can survive. It is a T-panel knob if Oliver wants it hotter.
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.pyinstead 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.pyfinds 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) continueguards. - Commit style: imperative summary + Co-Authored-By Claude line; push = deploy.