Load the game once, not on every launch

The cold-start complaints — late fonts, zombies missing from the stats
screen — were one header. server.py sent no-cache, must-revalidate for
everything, so a fresh launch revalidated ~230 sprites and three fonts
before the game had a full set of frames. Assets under /assets now cache
for a year when the URL carries ?v= (changing the file changes the URL,
which is what SPRV is for) and a day when it does not; the fonts get a
?v= too. HTML and JS stay no-cache, so a deploy is still visible.

Two client-side halves of the same report: a dropped sprite request used
to be permanent for the session — the sheet stayed undefined and the
zombie drew as some other design — so each one gets one retry. And the
splash now holds its buttons back behind a loading bar until the last
sheet has landed and both faces are ready, instead of letting you open
the stats screen while half of it is still in flight.

The bar is driven by setInterval, not rAF: rAF does not fire in a hidden
tab, and an app that loads while backgrounded would come back to a frozen
bar and a menu with no buttons on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-05 07:47:27 +02:00
1 parent a1a8a61422
commit c5a9746bc1
4 files changed
+80 -13

No files matched your search

+23 -5
View File
@@ -15,16 +15,19 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#2b2735">
<style>
/* pixel HUD font — bundled so it renders offline; used by the canvas scoreboard + rack */
/* pixel HUD font — bundled so it renders offline; used by the canvas scoreboard + rack.
?v= like the sprites: server.py caches a VERSIONED asset for a year and an unversioned one for
a day, so these three files are fetched once per release instead of revalidated on every cold
start. Bump alongside SPRV in render.js if a font file is ever replaced. */
@font-face { font-family: 'Silkscreen'; font-weight: 400; font-display: block;
src: url('assets/fonts/Silkscreen-Regular.ttf') format('truetype'); }
src: url('assets/fonts/Silkscreen-Regular.ttf?v=86') format('truetype'); }
@font-face { font-family: 'Silkscreen'; font-weight: 700; font-display: block;
src: url('assets/fonts/Silkscreen-Bold.ttf') format('truetype'); }
src: url('assets/fonts/Silkscreen-Bold.ttf?v=86') format('truetype'); }
/* the ANNOUNCEMENT face. Silkscreen is the HUD and the instructions — small, even, quiet. The
things that shout (SET, CHAPTER, EPIC!, PERFECT) are a second register on purpose, so a message
tells you which kind it is before you have read a word of it. Bundled, like the other one. */
@font-face { font-family: 'Bangers'; font-weight: 400; font-display: block;
src: url('assets/fonts/Bangers-Regular.ttf') format('truetype'); }
src: url('assets/fonts/Bangers-Regular.ttf?v=86') format('truetype'); }
/* html needs a real HEIGHT: the root element's background paints the viewport, but a GRADIENT is
laid out in the root's own box — and with the canvas position:fixed the root has no content and
collapses, so the gradient render.js sets had nowhere to go. */
@@ -207,6 +210,16 @@
/* the splash's bottom band: no bar, no rule — the court art has to keep running under it */
.foot.bare { border: 0; background: none; }
.foot.bare .btn.big { margin-bottom: 10px; }
/* the boot bar wears the stats screen's kill bar, doing duty as a loading bar. While #splash is
.busy it stands in the buttons' place — same band, same height, so nothing jumps when it goes. */
#boot { display: none; margin-bottom: 10px; }
#boot s { display: block; height: 6px; background: rgba(0,0,0,0.45); text-decoration: none; }
#boot s > i { display: block; width: 0; height: 100%; background: var(--acid);
transition: width 0.15s linear; }
#boot u { display: block; margin-top: 7px; text-align: center; text-decoration: none;
font: 400 9px Silkscreen, monospace; color: var(--dim); letter-spacing: 2px; }
#splash.busy #boot { display: block; }
#splash.busy .btn, #splash.busy .two, #splash.busy #build { display: none; }
#sSum { display: flex; justify-content: space-between; gap: 8px; margin: 0 0 8px;
font: 400 9px Silkscreen, monospace; color: var(--dim); letter-spacing: 1px; }
.tabs { display: flex; gap: 6px; margin: 12px 0 14px; }
@@ -284,6 +297,11 @@
<p class="slogan">Fifteen. Thirty. Forty. <b>Dead.</b></p>
</div>
<div class="foot bare">
<!-- THE BOOT BAR. Every sprite is requested the moment render.js is imported; this only
shows how far that got, and stands where the buttons will be until it is done — a menu
you can press before the sheets have landed is how you reach the stats screen with half
the zombies missing. Swapped by #splash.busy, see main.js. -->
<div id="boot"><s><i></i></s><u>LOADING</u></div>
<button class="btn big" id="mStart">PLAY MATCH</button>
<div class="two">
<button class="btn sec" id="mStats">STATS<i id="mKills">0 KILLS</i></button>
@@ -341,6 +359,6 @@
</div>
</div>
</div>
<script type="module" src="main.js?v=125"></script>
<script type="module" src="main.js?v=127"></script>
</body>
</html>
+25 -1
View File
@@ -6,7 +6,7 @@
import { TUNE, COURT, COURTS, TOURS, UMP_H, UMP_X, EATEN_RESTART } from './config.js';
import { P, O, ball, score, G, setupPoint, selectCourt, selectTour } from './state.js';
import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight , toggleTestMode } from './gameplay.js';
import { fitViewport, render, proj, RES, SPRV } from './render.js';
import { fitViewport, render, proj, RES, SPRV, LOAD } from './render.js';
import { voice, ambient, menuMusic, menuDuck } from './audio.js';
import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, explode, hurtPlayer, killPlayer , spawnBarrel } from './zombies.js';
import { cv } from './dom.js';
@@ -183,6 +183,29 @@ document.getElementById('stTabs').addEventListener('click', e => {
});
refreshSplash();
// THE BOOT GATE. render.js fires all ~230 sprite requests at import; this only watches them and
// keeps the splash's buttons out of reach until the last one has landed and both faces are ready.
// Without it a cold start could open the stats screen — which hangs <img> tags on the same files —
// or start a rally while half the sheets were still in flight, and the missing ones drew as
// whatever the fallback was. onerror counts too (render.js), so a dead network still finishes the
// bar rather than parking the player on it forever.
const splash = document.getElementById('splash'), bootFill = document.querySelector('#boot i');
splash.classList.add('busy');
const loaded = () => !splash.classList.contains('busy');
// setInterval, NOT requestAnimationFrame: rAF does not fire in a hidden tab, and a phone that
// launches the app and locks — or an install that loads in the background — would come back to a
// bar frozen at whatever it reached and a menu with no buttons on it. An interval still ticks
// (throttled to about a second), so the gate always opens.
const tick = setInterval(function watchLoad() {
const frac = LOAD.total ? LOAD.done / LOAD.total : 1;
bootFill.style.width = (frac * 100).toFixed(1) + '%';
if (frac < 1) return;
clearInterval(tick);
// fonts.ready resolves once nothing is pending — including the three faces warmed above — so the
// first canvas frame is never the system serif.
(document.fonts ? document.fonts.ready : Promise.resolve()).then(() => splash.classList.remove('busy'));
}, 100);
// THE FIRST TAP ANYWHERE ON THE OVERLAY STARTS THE THEME, and does nothing else — the screens have
// real buttons now. This is the gesture the autoplay policy is waiting for, which is why the theme
// is not started at boot. menuMusic itself is a no-op once the bed exists (loopBed's beds.has
@@ -203,6 +226,7 @@ addEventListener('keydown', e => {
const stage = intro.dataset.stage;
if (e.key === 'Escape') return go('splash');
if (stage === 'splash') {
if (!loaded()) return; // the boot bar hides the buttons; a key must not walk past it
if (e.key === 'Enter' || e.key === ' ') go('setup');
else if (e.key === 's' || e.key === 'S') openStats();
return;
+19 -6
View File
@@ -202,6 +202,10 @@ function drawRunOff() {
// menu screens hang <img> tags on the same files, and two copies of this number is one copy too
// many: the one that does not get bumped is the one that serves a cached 404.
export const SPRV = 86;
// The boot bar in main.js watches this. Nothing here CHANGES when things load — every sprite was
// already requested at import — it just makes the wait visible and holds the menu back until the
// last sheet has landed. See the onerror note below for the other half of "sometimes missing".
export const LOAD = { done: 0, total: 0 };
const SPR = {};
for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_run3','p_run4','p_swing1','p_swing2','p_die1','p_die2','p_die3','p_die4','p_die5',
'p_pull', // the serve wind-up: the racket dropping behind him while the pull is drawn
@@ -254,13 +258,22 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','
'z_c1_f','z_c2_f','z_c3_f','z_c4_f','z_cw1_f','z_cw2_f','z_cw1b_f','z_cw2b_f',
'scoreboard']) {
const img = new Image();
img.onload = () => SPR[n] = img;
// ?v=SPRV, for the same reason index.html versions main.js. python http.server sends no cache
// headers, so the browser caches sprite responses heuristically — INCLUDING 404s. A brand-new sheet
// whose name was added to this list before the PNG existed stays permanently "missing" for that
// browser, and drawZombie silently falls back to another design. That cost a debugging round.
// ?v=SPRV, for the same reason index.html versions main.js. It is also what buys the year-long
// immutable cache in server.py: a versioned URL can never go stale, because changing the file
// changes the URL. A brand-new sheet whose name was added to this list before the PNG existed
// stays permanently "missing" for that browser, and drawZombie silently falls back to another
// design. That cost a debugging round.
// BUMP THIS whenever assets/ changes, alongside main.js?v=N in index.html.
img.src = 'assets/' + n + '.png?v=' + SPRV;
const url = 'assets/' + n + '.png?v=' + SPRV;
// ONE RETRY. A dropped request used to be permanent for the whole session — the sprite stayed
// undefined, the zombie drew as some other design, and the stats screen showed a broken <img>.
// That is the "sometimes the graphics don't load" report. onerror still has to count on the
// second failure, or one genuinely missing file hangs the boot bar forever.
let retried = false;
LOAD.total++;
img.onload = () => { SPR[n] = img; LOAD.done++; };
img.onerror = () => { if (retried) LOAD.done++; else { retried = true; img.src = url + '&r=1'; } };
img.src = url;
}
// ------------------------------------------------------------------ crowd ----
+13 -1
View File
@@ -142,7 +142,19 @@ class Handler(SimpleHTTPRequestHandler):
# ---- static, with the headers the nginx conf used to set ----
def end_headers(self):
if not self.path.startswith('/api/'):
if self.path.startswith('/api/'):
pass # reply() sets its own no-store
elif self.path.startswith('/assets/'):
# THE COLD-START FIX. no-cache here meant ~230 sprites, three fonts and the audio were
# REVALIDATED on every launch — 230 round trips before the game had a full set of
# frames, on a phone, sometimes over a connection that dropped a few. That is why the
# fonts arrived late and the stats screen showed gaps. A ?v= URL can be cached forever
# because changing the file changes the URL (see SPRV in render.js); anything
# unversioned gets a day, so a replaced logo or icon still lands without a year of
# staleness. The HTML and JS stay no-cache — that is what makes a deploy visible.
self.send_header('Cache-Control', 'public, max-age=31536000, immutable'
if '?v=' in self.path else 'public, max-age=86400')
else:
self.send_header('Cache-Control', 'no-cache, must-revalidate')
super().end_headers()