From 64b297ce54acfeb75d28c5fc4056dae1bc14be72 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Mon, 27 Jul 2026 04:48:43 +0300 Subject: [PATCH] =?UTF-8?q?Add=20POLISH-PLAN.md=20=E2=80=94=20implementati?= =?UTF-8?q?on=20plan=20for=20the=20polish=20+=20bugfix=20round?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- POLISH-PLAN.md | 501 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 501 insertions(+) create mode 100644 POLISH-PLAN.md diff --git a/POLISH-PLAN.md b/POLISH-PLAN.md new file mode 100644 index 0000000..e4ed711 --- /dev/null +++ b/POLISH-PLAN.md @@ -0,0 +1,501 @@ +# 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. + +**Build:** make winning the duel the ONLY source of weapons. +- Delete the `tinDue` scheduling in `splatPoint()` and the `deliverTin()` call in + `startChangeover()`. Keep the "CHANGEOVER" flash. `G.tinDue` / `TUNE.ballChangeFirst` / + `ballChangeEvery` / `G.shameGame` all 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 `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.