Fourteen items from Oliver's phone-test list, written so a fresh session can execute them. Every root cause was verified against the code rather than guessed; the two "bugs" both turned out to be real and both have a named line: wCap 3 caps the stock, and weapons leak in through a SECOND delivery path (splatPoint's tinDue -> startChangeover) that has nothing to do with the duel. The serif complaint is also a bug, not taste: only 4 of 15 canvas font strings ask for Silkscreen, the rest say bare "monospace" and land on Courier. Ordered so the three foundations come first — the modal system (five separate popup requirements need the same input-gated pause, which does not exist yet), the UI colour constants, and the font fix — since the later items draw through all three. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
24 KiB
Splatter Tennis — Polish & Bugfix Plan
Written 2026-07-27 for a fresh Claude session. Everything below was verified against the
code at commit c8990fe; every "cause" line is something I actually read, not a guess.
Read first: HANDOFF.md (architecture, benchmarks, hard-won gotchas). This plan assumes it.
How to work in this repo
- Zero build. Plain ES modules served by
python3 -m http.servervia.claude/launch.json(preview_startwith{name: "splatter-tennis"}). config.js= all tuning (TUNEis live-editable in-game via theTpanel, which auto-lists every key — new keys appear for free).state.js= all mutable state onG.gameplay.js= rules/physics/update.render.js= all drawing.zombies.js= the splatter sim.input.js= keys + touch.main.js= boot + thewindow.Ttest surface.- Stale modules are the #1 time sink here. After editing, bump
main.js?v=Ninindex.htmlAND navigate to a fresh page URL (http://localhost:8641/?cb=<new>). Then sanity-check freshness before trusting any result:T.TUNE.someNewKey !== undefined. Bumping?v=also guarantees Oliver's phone picks up the build. - RAF pause state varies per session. Probe it; halt with
window.requestAnimationFrame = () => 0before deterministicT.steptests. - Synthetic touch needs a
touchmovebetween start and end or it reads as a tap. Contact tests must step1/60(coarser steps tunnel). - Verify each item with a runnable check before moving on. Screenshot the visual ones at
resize_windowmobile (375×812) — this is a phone game.
Order of work
Items 1–3 are foundations that later items depend on. Do them first, in order. Items 4–13 are independent after that and can be done in any order.
1. FOUNDATION: the modal system
Five separate requirements ("popup, game only continues after tapping it away") need the same thing, which does not exist yet. Build it once.
What exists today: G.card (state.js:60) + drawCard() (render.js:326) draw a
centred banner, but they are purely cosmetic — the game keeps running underneath on an
overT timer. There is no input-gated pause anywhere.
Build:
state.js — add to G:
modal: null, // {title, body: [lines], icon, color, onDismiss} — blocks play until tapped
seen: {}, // one-shot markers so a modal only fires the first time this run
gameplay.js — add near the card helpers:
export function showModal(m) { if (!G.modal) G.modal = m; }
export function dismissModal() {
const m = G.modal; if (!m) return false;
G.modal = null; if (m.onDismiss) m.onDismiss();
return true; // tells the caller the gesture was consumed
}
gameplay.js update() — freeze at the very top, after the flash/card decay so nothing
expires while the player reads:
if (G.modal) { G.idleT += dt; return; } // everything else waits
Put this AFTER the hitstop and eaten early-returns and AFTER G.flash/G.card decay,
but BEFORE the movement block. Rationale: the world must be completely frozen (no zombie
steps, no ball flight, no spawn timers) or a player reading a popup gets eaten.
input.js — dismissal must take priority over everything and swallow the gesture:
- In
touchstart, first line inside theforloop:if (dismissModal()) return; - In the
keydownhandler: any key dismisses. - Guard
canAimServe()with!G.modal.
main.js — maybeRestart must not fire through a modal.
render.js — a drawModal() drawn LAST (after the cards, before nothing). Spec:
- Full-screen scrim
rgba(8,6,10,0.82). - Optional icon sprite centred above the title (used by the weapon popup).
- Title in the UI accent colour, body lines below,
TAP TO CONTINUEblinking at the bottom. - Use the pixel font (see item 3) — this is new text, so do not introduce more
monospace.
Acceptance: with a modal open, T.step(1) × 60 must leave zombies[0].z, ball.z and
G.splatT unchanged; a synthetic touchstart clears G.modal and does NOT serve.
2. FOUNDATION: UI colour palette
Requirement: "Farbpalette etwas absoften (sehr starke rot und gelb töne in schrift und interaktiven elementen)".
Cause: the hot colours are hard-coded as hex literals scattered through render.js.
grep -o "#[0-9a-fA-F]\{6\}" render.js | sort | uniq -c gives the inventory. The offenders:
| current | where | proposed softer |
|---|---|---|
#f8e048 |
aim reticle, hints, NEW BEST, TAP TO PLAY AGAIN (5×) | #e0cc72 |
#ff5050 |
locked-target reticle, out-marker, SUDDEN DEATH card (3×) | #d9705f |
#e6ee3a |
selected rack slot, SET card (4×) | #cbd06a |
#e8f03a |
the ball itself (2×) | #dbe063 |
#c02020 |
GAME SET MASSACRE | #a34a42 |
#e0231a / #eef23a |
intro logo CSS in index.html |
see item 10 (logo art replaces it) |
Do NOT soften the gore reds (PAL/blood particles) — the splatter should stay loud;
only text and interactive UI were named.
Build: add a UI export to config.js next to TUNE (a plain object, not inside
TUNE — the T panel only renders numbers):
// UI accent colours, one place so the whole HUD can be re-tuned at once. Deliberately
// desaturated from the original arcade primaries: they were shouting over the artwork.
export const UI = { hot: '#d9705f', warm: '#e0cc72', pick: '#cbd06a', cream: '#e8e8dc',
dim: '#8fa0b8', bad: '#a34a42', ball: '#dbe063' };
Then replace the literals in render.js. Keep one commit for the mechanical swap so a
revert is trivial.
Acceptance: screenshot before/after at mobile size; no remaining #f8e048 / #ff5050
/ #e6ee3a / #c02020 in render.js.
3. FOUNDATION: the serif font bug
Requirement: "Die Serifen-Schrift ist nicht schön. Die sieht so generisch aus."
Cause — this is a real bug, not taste. index.html bundles Silkscreen and main.js
warms it, but only 4 of 15 canvas font strings actually ask for it. Everything else says
bold Npx monospace, which resolves to Courier — a slab serif. Confirmed:
grep -n "ctx.font = " render.js → lines 329, 330, 560, 562, 566, 569, 572, 575, 588, 597,
603 all use bare monospace. Lines 314, 316, 534 correctly use Silkscreen.
Build: render.js:534 already has the helper —
const F = (sz, b = 1) => \${b ? 700 : 400} ${sz}px Silkscreen, monospace`;Hoist it to module scope (it currently lives inside the scoreboard block) and route **every**ctx.font = through it. Silkscreen has no lowercase-friendly metrics at odd sizes; keep the existing px sizes but expect slightly different widths — re-check the centred strings (GAME, SET, MASSACRE.` at 16px is the widest) still fit at 270 logical px wide (portrait).
Also fix the #hint line in index.html (font: 12px monospace) — same serif, and it is
visible under the canvas on desktop.
Acceptance: grep -c "px monospace" render.js returns 0 except inside F's fallback
chain. Screenshot the eaten screen and a card in portrait; no serifs, nothing clipped.
4. BUG: weapon stock caps at 3
Requirement: "es können pro waffe immer nur drei gehalten werden. es sollten mehr waffen angespart werden können."
Cause: config.js:68 wCap: 3 and gameplay.js:336
const add = k => Math.min(TUNE.wCap, k); inside deliverTin().
Build: raise wCap to 9. Check the rack renderer handles two-digit counts:
render.js draws '×' + n in the slot at 6px — at 270px wide with 5 slots each slot is
~50px, so ×9 is fine but verify ×12 if you go higher. Recommend 9 and stop.
While here: deliverTin() gives TUNE.bombMax (3) bombs but hard-codes 3 triples and
3 flames and 1 gun. Pull those into TUNE (tinTriples, tinFlames, tinGuns) so the
economy is tunable from the panel like everything else.
Acceptance: T.deliverTin() four times in a row, then check
T.G.bombs > 3. Rack renders the count without overflowing its slot.
5. BUG: weapons arrive whether or not you win the duel
Requirement: "die waffen bekommt man unabhängig ob man das zombie 1:1 match gewonnen hat oder nicht."
Cause — there are two independent delivery paths and only one is gated on the duel:
resolveInterlude('P')→deliverTin()(gameplay.js:355) — the reward for winning.splatPoint()setsG.tinDueon a game schedule (ballChangeFirst/ballChangeEvery), andstartChangeover()(gameplay.js:310) callsdeliverTin()whentinDue— with no reference to any duel. This is the leak.
Note path 2 has a partial gate already: G.shameGame withholds the tin if they took a
game since the last delivery. That is not the same as winning the duel.
Build: make winning the duel the ONLY source of weapons.
- Delete the
tinDuescheduling insplatPoint()and thedeliverTin()call instartChangeover(). Keep the "CHANGEOVER" flash.G.tinDue/TUNE.ballChangeFirst/ballChangeEvery/G.shameGameall become dead — remove them rather than leaving vestigial state. - Keep
resolveInterlude('P') → deliverTin().
Consequence to check: duels only trigger when the court is clear and a racket zombie is
the last one standing (gameplay.js:~760). If that turns out to be rare, weapons become
near-unobtainable. Measure before shipping: run a 5-minute simulated session and count
duels. If it's under ~1 per two games, tell Oliver rather than silently re-adding a second
source — the design question (how often should duels happen?) is his call.
Acceptance: simulate a full game where the player loses the duel; G.bombs etc. must be
unchanged. Win one; exactly one tin arrives.
6. POPUP: entering splatter mode from tennis
Requirement: popup with an illustration explaining the new mechanic, stays until tapped.
Trigger: gameplay.js:410, inside startTransition()'s timeline —
once('go', 8.2, () => { G.mode = 'splatter'; ... }). Fire the modal there, after
setupPoint().
Content:
- Title:
THE MATCH HAS CHANGED - Body: pull back to aim · the further you pull, the harder it hits · a weak shot only staggers them
- Icon: a generated illustration (see Appendix A). Ask for a pixel-art panel showing the player pulling the slingshot aim back with a chevron trail toward a shambler — it should teach the gesture, not just decorate.
Because this is the mechanic tutorial, it is worth the biggest illustration of the set.
Consider a wider modal layout for this one (icon sized ~120×80 logical px).
Acceptance: enter splatter; the world is frozen and no zombie moves until tapped.
7. POPUP: entering the 1:1 duel (first time)
Requirement: popup saying it is a normal tennis game again and that winning earns a weapon. "momentan gibt es schon ein popup aber auf dem steht auch anderer unnötiger kram."
Cause: render.js:593 draws
drawCard('SUDDEN DEATH', 'WIN THE POINT · THE LINES ARE LIVE', 'THE POINT WINS YOU A NEW WEAPON', '#ff5050')
on a 2.4s timer (G.interlude.introT, set at gameplay.js:764). It is not dismissible, it
disappears on its own, and "THE LINES ARE LIVE" is the unnecessary cruft.
Build: replace the timed card with a real modal, gated on G.seen.duel:
- Title:
SUDDEN DEATH - Body:
IT'S TENNIS AGAIN — ONE POINT/WIN IT AND THEY HAND YOU A WEAPON - On dismiss: start the point.
- Delete the
introTcard path inrender.jsand theintroTcountdown inupdate()if the modal fully replaces the beat. KeepG.interlude.introTonly if the umpire voice line still needs the pause — checkvoice('sudden_death', 2.4)timing atgameplay.js:~766. - Subsequent duels in the same run: no modal, keep a short flash.
Acceptance: first duel opens a modal that blocks serving; second duel does not.
8. POPUP: weapon received
Requirement: shows and describes the weapon; game continues only after tapping.
Trigger: inside deliverTin() (gameplay.js:335), replacing the
G.flash = { text: 'NEW BALLS: ' + SLOTS[w+1] } line.
Content: icon = the existing rack sprite (w_bomb / w_triple / w_fire / w_gun —
already loaded in render.js's SPR), title = the weapon name, one line of description:
| slot | name | line |
|---|---|---|
| 1 | BOMB | detonates on contact — clears a crowd |
| 2 | TRIPLE | one serve, three balls |
| 3 | FIRE | kills on any touch and sets the others alight (item 12) |
| 4 | GUN | hold the line and spray — a burst, not a serve |
Careful: deliverTin() is also called from resolveInterlude() which is itself called
from killZombie() mid-iteration over zombies[] (zombies.js:51). Opening a modal there
is safe (it only sets state) but do NOT make showModal do anything that touches the
zombie array.
Acceptance: win a duel → modal with the right icon; play is frozen until tapped.
9. POPUP: the horde after a won set
Requirement: popup explaining you now have to shoot with the gun; blocks until tapped.
Trigger: proceedAfterClear() sets G.pendingHorde and shows the SET card
(gameplay.js:290-302); the horde starts when the overT beat expires (in update()'s
over branch → startHorde()).
Build: keep the SET card (it is the milestone beat and Oliver asked for it originally — see the memory note on milestone cards), then show the modal instead of auto-starting:
- Title:
THEY KEEP COMING - Body:
THE RACKET FIRES ON ITS OWN/SLIDE TO AIM · HOLD THE BASELINE - On dismiss:
startHorde().
Note startHorde() already pins P.z to the baseline and switches input to absolute
finger-steering (touchPX()), which is a genuinely different control mode — that is exactly
why the popup is warranted.
Acceptance: win a set → SET card → modal → horde only begins after the tap.
10. Start-screen logo
Requirement: "ein schöneres Splatter Tennis Logo für den Startscreen generieren."
Cause: the current logo is CSS text (index.html, #intro .l1 / .l2) — a hard red
#e0231a over hard yellow #eef23a with stacked text-shadows. Generic.
Build: generate a pixel-art logo PNG (Appendix A), drop it at assets/logo.png, replace
the two <span>s with <img>. Keep the drop animation and the blinking TAP TO START.
Prompt direction: bold pixel lettering, tennis-ball yellow-green and dried-blood red, a
dripping-gore underline, transparent background, roughly 640×280.
Soften the surrounding CSS colours to match item 2 while you are in there.
Acceptance: screenshot the intro at mobile and desktop; the logo must not overflow
max-width: 92vw and must stay crisp (image-rendering: pixelated on the img).
11. The brute takes 5 hits, with more wound states
Requirement: big zombie harder to kill (5 hits), correspondingly more wound states, sprites to be generated.
Cause: config.js:80 bruteHp: 2. render.js:160 has exactly one wound skin:
if (zb.variant === 'g' && zb.hp < TUNE.bruteHp && SPR.z_gw) name = 'z_gw';
Build:
bruteHp: 5.- Generate
z_gw2,z_gw3,z_gw4(Appendix A) as a progression: 4/5 hp = one arm torn off (that is today'sz_gw), 3/5 = both arms, 2/5 = jaw/half the head gone, 1/5 = barely standing, dragging itself. Register them inrender.js'sSPRpreload list (line ~75). - Select by hp:
(guard each with an
if (zb.variant === 'g' && zb.hp < TUNE.bruteHp) name = ['z_gw4', 'z_gw3', 'z_gw2', 'z_gw'][Math.max(0, zb.hp - 1)] || 'z_gw';SPR[...]existence check like the current code does, so a missing asset degrades to the walk frame instead of drawing nothing.) bleed()(zombies.js:158) picks wound anchors per variant — extend the brute branch so later stages bleed from more points. Keep it sparse; the settled feel note is explicit that blood must stay thin (seeHANDOFF.mdand the feel memory).
Balance check: the brute already soaks machine-gun pellets one per hit
(zombies.js pellet loop). At 5 hp it costs 5 pellets — fine during a horde, but verify a
brute arriving during normal play is still killable with the ~1.4s serve cycle before it
reaches the baseline. Measure time-to-kill vs time-to-baseline; if it is unwinnable, say
so rather than quietly lowering the hp Oliver asked for.
12. Fire spreads
Requirement: the fireball kills but ignites surrounding zombies; a burning zombie that walks near another sets that one alight too; needs a dynamic fire animation.
Cause: today ball.flame (zombies.js:245) is instant-kill-on-touch with full pierce,
and nothing else. The only fire visual is the ball's ember trail
(gameplay.js pushes parts with fire: true; drawn at render.js:496).
Build — new state on the zombie:
zb.burnT // seconds of burning left; undefined = not on fire
config.js:
burnTime: 3.0, // s a zombie burns before it collapses
burnDps: 1.0, // hp lost per second while burning (so burnTime*burnDps must exceed hp)
burnSpread: 1.3, // m — a burning body ignites anything this close
burnIgniteR: 2.6, // m — the flame ball's initial ignition radius around the kill
zombies.js:
- On a flame-ball contact: kill the struck zombie as now, then ignite every zombie within
burnIgniteR(zb.burnT = TUNE.burnTime). - In
updateZombies's per-zombie loop: ifburnT > 0, decrement it, applyburnDps * dtdamage (kill via the existingkillZombiepath so gore/scoring/ball-boy all still fire), emit 1–2fire: trueparticles per frame from the body, and ignite any un-burning zombie withinburnSpread. - Chain-reaction hazard:
killZombiesplices the array andsplatPointcan cascade. The existing loops guard withif (!zb) continue;— the ignition pass must do the same and must iterate backwards.HANDOFF.mdcalls this out explicitly; a fire chain is the worst case for it, so write the loop defensively and test with 15 zombies packed together. - Burning zombies should walk faster briefly (panic) — a nice touch and it makes fire a double-edged weapon. Optional; flag it to Oliver.
render.js — the dynamic animation:
- Draw flames on a burning body procedurally rather than as sprites: 3–5 vertically-stretched
flame quads per zombie, offsets driven by
Math.sin(G.idleT * f + zb.walkT)so each body flickers on its own phase, colours#ffc030→#ff8010→ dark smoke asburnTruns out. This is cheaper than a sprite sheet and reads better at 4 different burn stages. - Tint the zombie sprite toward orange while burning (
ctx.globalAlpha+ a second additive-ish pass, or just draw the sprite then a semi-transparent orange rect clipped to it — keep it simple at this resolution).
Acceptance: a flame serve into a line of 6 packed shamblers ignites the neighbours, the
fire visibly chains outward, and all 6 die within burnTime. No exception thrown (this is
the loop-mutation trap). Frame time stays sane with 14 burning bodies.
13. Death animation — the player becomes gore
Requirement: "Wenn man gefressen wird braucht es eine Endanimation, die zeigt dass man selbst zu gore wird."
Cause: zombies.js:229 flips G.mode = 'eaten' and render.js:506 simply stops
drawing the player (if (G.mode !== 'eaten') drawChar(P)). There is already a small burst
(30 particles + one gore decal) at the eat site, but the summary screen appears immediately
on top of it.
Build:
G.eatenTalready counts up from 0 (state.js). Use it as the animation clock.- Phase A (0 → ~0.5s): keep drawing the player, but jerking — a violent shake offset and a reddening tint, with the zombies crowding in. Kill the ball, freeze the score.
- Phase B (~0.5 → 1.4s): the sprite is replaced by an expanding gore burst — reuse
killZombie's machinery (it is the established look): a biggorestain, a size-4pilesentry atP.x/P.z, and ~90 particles with a widevyspread. AddG.shakeand oneboomFlashframe for the impact. - Phase C (from ~1.6s): the existing summary fades in (
render.js:559). GateTAP TO PLAY AGAINonG.eatenT > 2.2instead of0.7so the animation is not skipped by a panic tap. - The pile stays visible under the summary scrim — that is the point: you are the biggest gore mound on the court.
Acceptance: get eaten; screenshot at eatenT ≈ 0.3 / 0.9 / 2.0 and confirm three distinct stages. Restart still works.
14. Longer changeover, and the ball boys get a mop
Requirement: "changeover sollte etwas länger dauern. die balljungen, die die reinigung machen brauchen einen mop."
Cause: config.js:33 changeoverT: 4.5. drawBoy() (render.js:136) uses
x_boyrun1..4 — a plain 4-frame run cycle, no prop.
Build:
changeoverT: 7.0. The changeover already suppresses spawns and serving (zombies.jsspawn gate,keyPressedguard), so a longer one is a real breather. Check it does not feel dead — if it does, have more boys cross.- Generate
x_boymop1..4(Appendix A): the same boy, bent over, pushing a mop, 4 frames of a push cycle. drawBoy()picks the sheet by intent. TodaysendBoy()is used for BOTH "fetch the dead ball" and "mop this splat" (state.js:95, called fromkillZombiewith the corpse position). Add a flag:sendBoy(tx, tz, mop = false), setmop: truefromkillZombieand fromstartChangeover, and selectx_boymop*vsx_boyrun*accordingly. The mopping boy should also move slower — mopping is not sprinting.- The gore-cleaning already happens in
updateZombies's boy pass (d.a -= dt * 1.6); with a mop sprite that finally reads as why the stain fades.
Acceptance: trigger a changeover; the boy crosses with the mop sprite, stains visibly shrink behind him, and the breather runs 7s.
Appendix A — generating sprites and illustrations
The pipeline is documented in HANDOFF.md under "Gotchas learned the hard way"; the short
version:
- Use the
generate-imageskill. It needsOPENROUTER_API_KEY, which is already in the repo's gitignored.env. Run generation scripts with a scratchpad venv python (system python lacksrequests); the venv is wiped between sessions, so recreate it withrequests+pillow. - Ask for a magenta background and, for multi-frame sheets, frames in a row separated by clear gaps.
- Slice with the committed
assets/_slice.py:python3 assets/_slice.py sheet.png out1.png out2.png ...(add--largestwhen frames touch and stray fragments bridge the gap). - Register new names in
render.js'sSPRpreload array (~line 71).
Known generator quirks (all from HANDOFF.md, all still true):
- It sometimes ignores the magenta instruction — harmless, the key uses the corner colour.
- It often draws near-identical animation frames. Re-prompt with strongly contrasted pose descriptions.
- Passing the existing sheet as
--input(edit mode) is the reliable way to match style. Do this for every asset here —_sheet_brute.pngfor the brute wounds,_sheet_boyrun.pngfor the mop boy — the style must match what is already on court.
Assets this plan needs:
| file(s) | source sheet to match | item |
|---|---|---|
z_gw2, z_gw3, z_gw4 |
_sheet_brute.png / _sheet_gwound.png |
11 |
x_boymop1..4 |
_sheet_boyrun.png |
14 |
assets/logo.png |
— (new, standalone) | 10 |
| transition tutorial illustration | — (new, standalone) | 6 |
The two popup illustrations and the logo are not chroma-keyed sprites — generate them
with transparent or dark backgrounds sized for their use and skip _slice.py.
Appendix B — things to check before declaring done
HANDOFF.md's benchmarks must still hold: CPU returns 11/12 serves; 30 simulated points with human-like tapping = 50 hits / 0 whiffs. Items 2, 3 and 11 can all disturb these.- The settled feel decisions are recorded and must not be regressed silently: the two-stage serve, the manual wave clear, thin dynamic blood, one-gesture-means-one-thing on touch. If any item here would break one, flag it to Oliver instead of deciding.
- Update
HANDOFF.mdwith anything new that bit you. - Bump
main.js?v=Nin the final commit so phones get the build.