Files
splatter-tennis/POLISH-PLAN.md
T
wuwiandClaude Opus 5 64b297ce54 Add POLISH-PLAN.md — implementation plan for the polish + bugfix round
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>
2026-07-27 04:48:43 +03:00

502 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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=<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 = () => 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 `<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'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.