# 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.server` via `.claude/launch.json` (`preview_start` with `{name: "splatter-tennis"}`). - `config.js` = all tuning (`TUNE` is live-editable in-game via the `T` panel, which auto-lists every key — new keys appear for free). `state.js` = all mutable state on `G`. `gameplay.js` = rules/physics/update. `render.js` = all drawing. `zombies.js` = the splatter sim. `input.js` = keys + touch. `main.js` = boot + the `window.T` test surface. - **Stale modules are the #1 time sink here.** After editing, bump `main.js?v=N` in `index.html` AND navigate to a fresh page URL (`http://localhost:8641/?cb=`). 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 = () => 0` before deterministic `T.step` tests. - Synthetic touch needs a `touchmove` between start and end or it reads as a tap. Contact tests must step `1/60` (coarser steps tunnel). - Verify each item with a runnable check before moving on. Screenshot the visual ones at `resize_window` mobile (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`: ```js 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: ```js 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: ```js 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 the `for` loop: `if (dismissModal()) return;` - In the `keydown` handler: 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 CONTINUE` blinking 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): ```js // 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:** 1. `resolveInterlude('P')` → `deliverTin()` (`gameplay.js:355`) — the reward for winning. 2. `splatPoint()` sets `G.tinDue` on a game schedule (`ballChangeFirst`/`ballChangeEvery`), and `startChangeover()` (`gameplay.js:310`) calls `deliverTin()` when `tinDue` — 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. **The design decision (Oliver, 2026-07-27):** duels must come round on their own after a while, "damit es immer die chance auf waffen gibt". So the schedule is not the problem — it is attached to the wrong event. **Move it from delivering tins to calling duels.** The cadence stays, the reward becomes something you have to earn, and the umpire-restores-order framing is unchanged. **Why the duel is rare today:** the trigger at `gameplay.js:~760` requires `zombies.length === 1 && zombies[0].variant === 'r' && !zombies[0].dueled` — the court has to be completely empty apart from one never-yet-dueled racket zombie. Racket zombies only appear when you win a game (`splatPoint`) or survive a horde (`endHorde`), and fresh shamblers spawn again the moment a clear finishes. So it fires by luck. **Build:** - Rename the schedule to what it now schedules: `TUNE.ballChangeFirst`/`ballChangeEvery` → `duelFirst`/`duelEvery`, `G.tinDue` → `G.duelDue`. Keep the same arithmetic in `splatPoint()`. Suggested start: first duel after game 2, then every 3. - Delete the `deliverTin()` call in `startChangeover()` (keep the "CHANGEOVER" flash). `G.shameGame` — which withheld a tin if they took a game — has nothing left to gate; remove it rather than leaving vestigial state. - Keep `resolveInterlude('P') → deliverTin()` as the only weapon source. - **Fire the scheduled duel off the existing clear beat.** `proceedAfterClear()` (`gameplay.js:290`) already runs at the one moment the court is guaranteed empty — the player just cleared the wave by hand, and no new shamblers have spawned yet. That is the natural slot. There: if `G.duelDue`, consume it, ensure a racket zombie exists (`spawnRacketZombie()` if none, and clear `dueled` on the existing one so a veteran can be challenged again), and start the interlude instead of the changeover/game beat. The changeover follows after the duel resolves. - Leave the existing opportunistic trigger in place. The schedule is a floor, not a ceiling — a lucky clear can still produce a bonus duel, which is pure upside for weapon supply. **Why this slot and not a timer:** clearing the wave is already mandatory before play advances (`G.clearing`, a settled feel decision Oliver was emphatic about), so the schedule can never stall. A wall-clock timer would need its own "wait for a safe moment" logic and could fire mid-swarm. **Acceptance:** - Simulate ~10 games. A duel must occur at game 2, 5, 8 … regardless of how the waves fall. - Lose a duel → `G.bombs`/`triples`/`flames`/`guns` unchanged, and the next duel still arrives on schedule. - Win one → exactly one tin, and the item-8 modal names it. - No path reaches `deliverTin()` except `resolveInterlude('P')` (`grep -n "deliverTin" *.js` should show the definition, that one call, and the `window.T` test export — nothing else). --- # 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 `introT` card path in `render.js` and the `introT` countdown in `update()` if the modal fully replaces the beat. Keep `G.interlude.introT` only if the umpire voice line still needs the pause — check `voice('sudden_death', 2.4)` timing at `gameplay.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 ``s with ``. 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's `z_gw`), 3/5 = both arms, 2/5 = jaw/half the head gone, 1/5 = barely standing, dragging itself. Register them in `render.js`'s `SPR` preload list (line ~75). - Select by hp: ```js 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'; ``` (guard each with an `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 (see `HANDOFF.md` and 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:** ```js zb.burnT // seconds of burning left; undefined = not on fire ``` `config.js`: ```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: if `burnT > 0`, decrement it, apply `burnDps * dt` damage (kill via the existing `killZombie` path so gore/scoring/ball-boy all still fire), emit 1–2 `fire: true` particles per frame from the body, and ignite any un-burning zombie within `burnSpread`. - **Chain-reaction hazard:** `killZombie` splices the array and `splatPoint` can cascade. The existing loops guard with `if (!zb) continue;` — the ignition pass must do the same and must iterate backwards. `HANDOFF.md` calls 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 as `burnT` runs 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.eatenT` already 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 big `gore` stain, a size-4 `piles` entry at `P.x/P.z`, and ~90 particles with a wide `vy` spread. Add `G.shake` and one `boomFlash` frame for the impact. - Phase C (from ~1.6s): the existing summary fades in (`render.js:559`). Gate `TAP TO PLAY AGAIN` on `G.eatenT > 2.2` instead of `0.7` so 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.js` spawn gate, `keyPressed` guard), 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. Today `sendBoy()` is used for BOTH "fetch the dead ball" and "mop this splat" (`state.js:95`, called from `killZombie` with the corpse position). Add a flag: `sendBoy(tx, tz, mop = false)`, set `mop: true` from `killZombie` and from `startChangeover`, and select `x_boymop*` vs `x_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: 1. Use the `generate-image` skill. It needs `OPENROUTER_API_KEY`, which is already in the repo's gitignored `.env`. Run generation scripts with a scratchpad venv python (system python lacks `requests`); the venv is wiped between sessions, so recreate it with `requests` + `pillow`. 2. Ask for a **magenta background** and, for multi-frame sheets, frames in a row separated by clear gaps. 3. Slice with the committed `assets/_slice.py`: `python3 assets/_slice.py sheet.png out1.png out2.png ...` (add `--largest` when frames touch and stray fragments bridge the gap). 4. Register new names in `render.js`'s `SPR` preload 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.png` for the brute wounds, `_sheet_boyrun.png` for 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.md` with anything new that bit you. - Bump `main.js?v=N` in the final commit so phones get the build.