- Fire never spread in play. The flame ball's full pierce wiped its own lane, so
the bodies that would have caught were already gone. It now ignites everything
it FLIES PAST (TUNE.burnBallR), with a wider ignition and spread radius. One
flame serve into a scattered 10-wave kills 9, nine burning at once.
- Wounded brutes slid along like decals: every wound stage is a single frame and
the brute now spends most of its life wounded. They get a procedural lumber
instead of eight more sprites — translate + squash, no rotation, so the pixels
stay on the grid, and the amplitudes scale with the projection or the whole
thing goes sub-pixel at range.
- The "scoreboard flicker" was the crowd: the cheer bounce was per-column
Math.random() every frame, i.e. TV static, showing through the scoreboard art's
transparent ornate edges. Now a smooth per-column sine on idleT.
- Ball boys: mopping is the default in splatter mode now, and the boy is half
again as tall — at 1.2m he read as a distant speck. The running fetch-boy
survives for tennis, where there is nothing on the grass but a ball.
- Changeover 7s -> 5s, with the mop crossing timed to fill it.
- Save-to-homescreen: a bloody tennis ball, wired up as apple-touch-icon +
favicon + webmanifest. The Dockerfile now ships the manifest and gives it its
own MIME type via an exact-match location (a bare `types {}` block would have
replaced nginx's whole map and broken .js and .png with it).
Benchmarks re-run: CPU returns 67/72 serves (93.1%, above the 11/12 bar — a
20/24 sample in between was flub noise), 30 human-tapped points = 50 hits /
0 whiffs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
15 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.
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 widerburnIgniteR4.5 /burnSpread2.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 withp.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()*2per column per frame, i.e. TV static, showing through the scoreboard art's transparent ornate edges. Now a smooth per-column sine onidleT. Test for it: render twice WITHOUT stepping — identical pixels means nothing in the draw path is rolling dice per frame. - Ball boys:
sendBoy'smopnow DEFAULTS toG.mode === 'splatter'. The running fetch-boy still exists, but only in tennis mode where there is nothing on the grass but a ball.BOY_H1.2 → 1.5. TUNE.burnSpreadis a T-panel knob if Oliver wants fire hotter still.
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.