Oliver's call: duels have to come round on their own so there is always a shot at a weapon. So the game-count schedule isn't the bug — it's attached to the wrong event. Item 5 now moves it from delivering tins to calling duels: same cadence, same umpire-restores-order framing, but the weapon has to be earned. Fires off proceedAfterClear(), which is the one moment the court is guaranteed empty (the wave was just cleared by hand and nothing has respawned). Clearing is already mandatory before play advances, so the schedule can't stall — a wall-clock timer would need its own wait-for-a-safe-moment logic and could fire mid-swarm. The existing opportunistic trigger stays as a bonus path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
26 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.
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 insplatPoint(). Suggested start: first duel after game 2, then every 3. - Delete the
deliverTin()call instartChangeover()(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: ifG.duelDue, consume it, ensure a racket zombie exists (spawnRacketZombie()if none, and cleardueledon 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/gunsunchanged, and the next duel still arrives on schedule. - Win one → exactly one tin, and the item-8 modal names it.
- No path reaches
deliverTin()exceptresolveInterlude('P')(grep -n "deliverTin" *.jsshould show the definition, that one call, and thewindow.Ttest 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
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.