The freeze first: G.mgT only counted down inside `G.phase === 'rally'`, so anything that moved the phase mid-burst — a scheduled duel calling setupPoint — stranded it above zero forever. mgT > 0 is one of keyPressed's early returns, so no tap could serve again, and the yellow shape over the player's head was drawMgRacket's muzzle flash still being drawn. Fixed at the root (the clock ticks in every phase, only the pellets are gated on rally) and semantically: starting a duel cancels any burst, because the duel is tennis. The mop boy now does an actual round: walk to the biggest nearby stain, stand on it and push the mop while the cycle animates in place, scrub it out, move to the next, then leave. Gore fades ~3.5x faster while he is stopped and working than when walking past, so the stain dies under him instead of fading as he drifts by. boyMaxJobs caps the round so a kill streak can't employ him forever. Applause: the clip is 4.16s and always plays in full, but every cheer() passed a shorter beat, so the stand sat down while it was still audible. Floored at the clip length. New, much bolder clap frame — the re-roll came back brighter and bluer, which would have flashed the whole stand, so it is colour-matched to frame one per channel on mean and sd before quantising. Net and umpire were both wrong since forever: netW was the singles-stick width (10.06) against a court drawn to the doubles lines, so the net stopped 0.46m short each side; the chair's near legs stood 0.45m inside the sideline. Both now sit exactly on the doubles line. 26-game soak clean, no stalls. Serve returns 134/150 = 89.3% +/-2.5% with the wider net (the bar is inside one sigma), 30 human-tapped points = 50 hits / 0 whiffs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
20 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.
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.distfor ball boys,zb.z / TUNE.bruteStridefor 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.
sendBoyno 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_crowdseated /x_crowd2arms 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.
Third feedback round (2026-07-27)
- THE FREEZE (serving dead, gun racket stuck over your head).
G.mgTonly counted down insideif (G.mgT > 0 && G.phase === 'rally'). Anything that moved the phase mid-burst — a scheduled duel callingsetupPoint()— strandedmgTabove zero forever, andmgT > 0is one ofkeyPressed's early returns, so no tap could ever serve again. The yellow blob Oliver saw wasdrawMgRacket's muzzle flash still being drawn. Fixed at the root (the clock ticks in every phase; only the pellets are gated on 'rally') and semantically (startDuelcancels any burst — the duel is tennis). If another "can't serve" report ever comes in, check these flags first:G.mgT,G.horde,G.changeover,G.interlude.introT— each one blockskeyPressed. - The mop boy has a job now (
mopRoundin gameplay.js): walk to the biggest nearby stain, stand on it forTUNE.boyMopDwellwhile the push cycle animates in place, scrub it out, pick the next, then walk off.TUNE.boyMaxJobscaps the round so a kill streak can't employ him forever. Gore fades ~3.5× faster while he is actually stopped and mopping than when merely walking past. A straight-line constant-speed crossing was what read as moonwalking past the mess. - Applause is floored at the audio length.
assets/audio/tennis-applaus.wavis 4.16s and always plays out in full, but everycheer()call passed a shorter beat, so the stand sat down while the clap was still audible.cheer()now doesMath.max(crowd.cheer, seconds, APPLAUSE_T). - Regenerating a crowd frame: colour-match it or don't ship it. The bolder re-roll came back much brighter and bluer; swapping to it would have flashed the whole stand. Fixed by a per-channel linear map onto frame one's mean and sd (means now agree to within half a level), then quantised to 64 colours. Verify with a mean-difference number before trusting any regenerated frame.
- Net and umpire were both wrong since forever.
COURT.netWwas the singles-stick width 10.06 while the court is drawn to the doubles lines, so the net stopped 0.46 m short each side — now 10.97, flush.UMP_X5.6 put the chair's near legs 0.45 m inside the doubles line (x_ump is 42×112 px at UMP_H 3.0 = 1.12 m wide as drawn); 6.05 stands them exactly on the line. Do not push past ~6.3 or the chair clips the right edge of the portrait viewport. The wider net is a real physics change (balls between |x| 5.03 and 5.485 now catch the tape); serve returns measured 134/150 = 89.3% ±2.5% afterwards, bar still inside one sigma.
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.