A Phaser loading screen can't cover its own bundle
Short version: a loading screen built with Phaser cannot cover the wait for
Phaser, because it arrives in the same bundle. Cook Line’s is 419 kB gzipped
(1,802 kB unminified), and none of it runs until all of it has downloaded and
parsed. A LoaderPlugin progress bar covers asset loading — the second half
of a cold start.
The first half needs plain HTML in index.html, with no script and no external
request. Measured on localhost with zero transfer time: first contentful paint
at 72ms, handover to the game at 601ms.
The two halves of a cold start
A player opening the game waits for two things in sequence:
- The bundle. Download, parse, evaluate. Phaser doesn’t exist yet. Neither does any scene, any preloader, or any progress bar you wrote.
- The assets. Images, audio, fonts — everything
LoaderPluginpulls inpreload(), which is what Phaser’s progress events describe.
Every Phaser loading-screen tutorial covers step 2. Step 1 is structurally out of reach for anything written in the framework, and it isn’t the small half.
The current build, measured with vite build at Vite 6.0.7:
| Artifact | Size | Gzipped |
|---|---|---|
index-*.js |
1,802.16 kB | 419.20 kB |
index.html |
4.90 kB | 2.16 kB |
index-*.css |
1.34 kB | 0.64 kB |
Total built output is 5.07 MB, but most of that is assets fetched later — two music tracks account for 1.89 MB between them, and the largest single image is 643 kB. The 419 kB gzipped bundle is the part that blocks everything. (All sizes here are decimal kB and MB, matching what Vite prints.)
I’m deliberately not quoting a wall-clock figure for a phone on mobile data. I haven’t measured one on a throttled profile, and a modelled “N seconds at M Mbps” number is exactly the kind of unverified specific that this devlog keeps getting wrong. What I can measure is the floor, which turns out to make the point on its own.
Measuring the gap on localhost
Serving the production build from python3 -m http.server and instrumenting the
page with a PerformanceObserver for paint timings and a MutationObserver
watching for the splash’s removal:
| Event | Time from navigation |
|---|---|
| First contentful paint — the inline splash | 72ms |
DOMContentLoaded |
141ms |
| Splash removed, game visible | 601ms |
load |
1,393ms |
Localhost means transfer time is essentially zero. So that 529ms between paint and handover is almost entirely parse, evaluate, construct the game, and clear the font gate — work that happens after the bytes have arrived and would happen on a phone too, on top of however long the download takes.
Which is the argument in one number: even with a perfect network, there is half a second the Phaser loading screen cannot be on screen for, because during it Phaser is still becoming a thing that exists. Add a real connection and the gap widens by the download time for 419 kB; it never narrows.
Layer one: the splash is inline HTML
The splash lives in index.html as markup and a <style> block, and that is
the entire trick. No script, no stylesheet link, no image, no web font:
<style>
:root { color-scheme: dark; }
html, body { margin: 0; height: 100%; background: #17121e; }
#boot {
position: fixed;
inset: 0;
transition: opacity 220ms ease-out;
}
</style>
Anything it referenced would be another round trip, and a round trip is the
thing being hidden. An external stylesheet would be render-blocking; an <img>
logo would paint late and pop in; a web font would either block or swap.
That last one is a real decision rather than a purity exercise. The game’s pixel font is still loading at this point, and swapping it in under a title that has been on screen for a second reads as a glitch. The splash uses the system stack and looks deliberate; matching the game’s typography here would cost more than it buys.
The background colour is the same #17121e the game uses, so the handover reads
as one continuous screen rather than a flash between two of them.
Layer two: handing over without an import cycle
The function that dismisses the splash is its own module with no imports at all:
export function dismissBootSplash(): void {
const splash = document.getElementById('boot');
if (!splash) return;
splash.classList.add('is-done');
splash.addEventListener('transitionend', () => splash.remove(), { once: true });
window.setTimeout(() => splash.remove(), 400);
}
Seven lines of body, in a 21-line src/bootSplash.ts that is mostly comment.
The obvious place for it is main.ts, and
that is a trap worth naming: MainScene is what calls it, and main.ts imports
MainScene. Putting the function in main.ts makes a cycle.
The cycle wouldn’t error. ES modules handle circular imports by hoisting
function declarations, so the call resolves — but only because it happens late,
after both modules have finished evaluating. It works by timing, not by
construction. Move the call earlier, or convert the function to a const arrow
binding, and it becomes a ReferenceError in a file nobody edited.
A module with zero imports cannot participate in a cycle. That’s not a refactoring preference; it’s the property that makes the correctness independent of when the call happens.
The failure nobody tests: transitionend never fires
dismissBootSplash removes the splash two ways, and the second one is not
belt-and-braces — it is the only thing that removes the splash at all for a
visitor with reduced motion enabled:
splash.addEventListener('transitionend', () => splash.remove(), { once: true });
window.setTimeout(() => splash.remove(), 400);
The condition that makes the timer load-bearing is forty lines below the splash
markup, in the same inline <style> block:
@media (prefers-reduced-motion: reduce) {
#boot-fill { animation: none; width: 100%; opacity: 0.55; }
#boot { transition: none; }
}
transition: none. Not shortened — removed. CSS Transitions Level 1
is explicit: “The transitionend event occurs at the completion of the
transition.” No transition, no completion, no event — for every visitor with
reduced motion enabled the listener never fires. This isn’t a speculative edge case in someone
else’s browser. It’s a rule in our own CSS, written for good reasons, sitting in
the same <style> block as the element it disarms the listener for.
A background tab does something similar for a different reason: rendering is throttled, and a transition that never progresses never completes.
In both cases the splash reaches opacity: 0 and stays in the DOM forever.
I want to be exact about what that costs, because the comment in our own source overstates it — it says the stranded splash would swallow the first tap, and it wouldn’t:
#boot.is-done { opacity: 0; pointer-events: none; }
pointer-events: none goes on in the same rule as the fade, so input passes
straight through. The two guards are independent, and between them the failure
degrades from “the game silently eats taps” to “a dead fixed-position element
sits in the DOM and the accessibility tree.” That’s a real bug and a
substantially milder one.
Which is its own small lesson. Both defences were written; only one of them was
described accurately afterwards. The comment claims a severity the CSS had
already prevented, and had anyone trusted it while refactoring — deleting the
timeout because “pointer-events: none handles it,” or dropping
pointer-events: none because “the timeout handles it” — they’d have removed
the wrong one based on a description that was never true.
The general rule is worth stating without the CSS specifics: never make removal depend solely on an animation event. Animation events are best-effort notifications about rendering. If something must happen, a timer has to guarantee it, and the animation event is only there to make it happen sooner.
Racing document.fonts.load, with a cap
The web font gates the handover too, and it fails the same way the transition does.
Phaser rasterizes canvas text when each Text object is constructed:
Text’s constructor calls setText, which calls updateText, which runs
context.fillText and builds a texture from the canvas (Text.js, Phaser
4.1.0, lines 296, 655 and 1470). A font that arrives after the first scene is
built therefore doesn’t restyle anything — the glyphs are already pixels.
Waiting for the font before starting avoids a first frame in the fallback face.
But FontFaceSet.load resolves “with the result of waiting for all the
[[FontStatusPromise]]s of each font face in the font face list”
(CSS Font Loading Module Level 3), and a request that never
answers leaves its status promise pending indefinitely. Unbounded, a stalled
fetch from a portal’s CDN isn’t “text in Arial” — it’s a boot splash that never
hands over, which is the one failure the splash cannot communicate, because the
splash is the thing that’s stuck.
So the wait is a race:
const FONT_GATE_MS = 3000;
const fontReady = Promise.race([
document.fonts.load('16px "Cook Line Font"'),
new Promise((resolve) => window.setTimeout(resolve, FONT_GATE_MS))
]);
void fontReady.catch(() => undefined).finally(createGame);
The comment above FONT_GATE_MS in our source says 3000ms “match[es] the block
period font-display: swap already applies in CSS.” That is wrong, and I only
found out by going to check it while writing this paragraph. CSS Fonts Module
Level 4, §4.9:
swap — Gives the font face an extremely small block period (100ms or less is recommended in most cases) and an infinite swap period.
block — Gives the font face a short block period (3s is recommended in most cases) and an infinite swap period.
3s is block’s number. We ship font-display: swap (src/style.css:28), so
the browser stops blocking a DOM paint for this face about 2.9 seconds before
our canvas gate expires. The comment’s reasoning — the canvas shouldn’t hold out
longer than the document does — argues for a 100ms cap, not a 3000ms one.
The cap is still worth having; it just isn’t derived from anything. The honest
version is that 3s is the longest block period the platform ever recommends for
any font-display value, so it’s a defensible ceiling on “how long is it
reasonable to hide a game behind a font.” That’s a judgement call, and writing
it down as a spec-derived constant made it look like it wasn’t.
Both mechanisms here are the same pattern. A promise or an event that usually resolves is a liveness bug wearing ordinary clothes, and the fix in each case is a timer that owns the outcome.
What I took from this
The reusable idea isn’t about Phaser. It’s that a loading indicator has to live outside the thing it reports on, and it’s easy to miss that a framework’s loading screen is inside its own bundle — the code looks like it runs first because it’s the first thing that happens in your game.
So the question to ask of any loading UI: what does the user see during the interval before this code can run? For a bundled game engine that interval is the whole download and parse, and the only thing that can fill it is markup the HTML parser meets on its first pass.
The rest is refusing to trust events. transitionend and FontFaceSet.load
both resolve reliably in every condition anyone tests, and neither is guaranteed
to. When something must happen, a timer has to be the one that guarantees it.
The unplanned finding is that both comments explaining these two guards were
wrong — the one in bootSplash.ts claiming a stranded splash swallows the first
tap, and the one in main.ts deriving 3000ms from font-display: swap. The
code was right in both cases; only the reasoning written beside it was false,
which is the version that survives longest, because nothing ever fails. Writing
this post is what checked them. That’s a poor process to depend on, and the only
one that has caught anything so far.
Numbers above are Vite 6.0.7 and Phaser 4.1.0, measured on the current build, served from localhost in headless Chrome 151. Bundle sizes move with every asset change — measure your own rather than trusting these.
Two related things from the same project: the volume setter that looks
dead and a font rule we had written down and never tested.
On the site side, a drop-shadow that never once rendered and a scoped
selector that matched nothing are the same species as the
transitionend case — code that is present, valid, and does nothing under a
condition no one browses in.