Files
splatter-tennis/POLISH-PLAN.md
T
wuwiandClaude Opus 5 5faaa99467 POLISH-PLAN: schedule the DUEL, not the tin
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>
2026-07-27 04:53:00 +03:00

26 KiB
Raw Blame History

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:

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 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):

// 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 <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:
    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:

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: 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.