Establishing Combat Outpost · Engineering Report · 2026-06-06

The Deploy Loading Screen

"They click Deploy and nothing happens for several seconds before the game screen. I'd like a loading screen — a spinner, a progress bar if we have one, and a list of what's happening."

0.2 ms
until the loading screen paints
(was: a frozen button)
40 steps
the bar advances through the
~6 s relief bake (was: frozen)
instant
first deploy frame
(pre-warmed, no freeze)

1 · Why nothing happened for several seconds

Clicking Deploy called newCampaign(), which did all of the deploy work synchronously inside the click handler and only then switched the screen. JavaScript is single-threaded: while that work runs, the browser cannot paint. So React never got a chance to show anything — the button just sat there, frozen, until the work was done.

To find where the seconds actually went, I drove the real running app over the Chrome DevTools Protocol and timed every phase. The result named a villain I did not expect:

Deploy phaseCostWhere
Terrain heightmap (512², rivers, roads)~319 mscreateTerrain()
Platoon, villages, civilians, COP~6 mscreateWorld()
Relief bake — a 4096² (16.7 M-pixel) shaded map~5,900 msbakeTerrain()
Sprite atlas (164 SVGs)~265 msloadSprites()
Total click → playable~6.5 sall frozen, zero feedback
The hidden villain
The relief bake renders the whole valley to a 4096×4096 offscreen bitmap — one pixel at a time, ~16.7 million of them. And it was lazy: it fired on the first animation frame after the deploy screen mounted. So even a naive loading screen would have flipped to the game and then frozen solid on the bake. The fix had to move the bake into the loading screen and show progress through it.

2 · The fix — stage the work, and yield to a paint between each step

The core trick is small and worth understanding. To make a loading screen actually appear, you must let the browser paint before you start the heavy work — and a single requestAnimationFrame fires before the paint, not after. So we wait for two frames (the previous commit is guaranteed on-screen by the second one):

// state/store.ts — wait for the browser to ACTUALLY paint, not just schedule a frame
const nextPaint = () => new Promise(res =>
  requestAnimationFrame(() => requestAnimationFrame(() => res())));

// each phase: show it as "active" + PAINT, THEN run the (often blocking) work
steps[i].status = "active";
snap(i / phases.length);
await nextPaint();          // the spinner + label are on screen first
await phases[i].run(report); // now block — the user is watching it happen

Three pieces make the deploy stageable without changing a single simulated value:

a. A terrain seam (determinism-safe)

Generation used to build its own terrain internally. I exposed createTerrain(seed) and let createWorld(seed, days, terrain?) accept a pre-built one, so the heightmap is its own visible phase and is built exactly once. The terrain carries its own independent RNG, so reusing a same-seed terrain leaves every other draw in the same order — the resulting world is byte-identical. Proven across 5 seeds before anything else was built.

b. A progressive relief bake

The 6-second bake was one tight pixel loop. I split it into a shared processRows(y0,y1) used by two drivers: the original synchronous bakeTerrain (for the live-draw fallback) and a new bakeTerrainProgressive that bakes in 40 row-bands, reports 0→1 and yields a frame between bands, and writes into the same cache the renderer reads. So the bar fills smoothly through the bake — and because the cache is warm, the first deploy frame is a pure cache hit instead of a 6-second freeze.

c. The screen itself

A phase checklist (live spinner / ✓ / ○), a "PHASE n / N" + percentage, a gliding progress bar, a faint military map-grid (the valley being plotted onto a map sheet), and a rotating field-manual line. It reuses the menu's exact palette so the deploy feels continuous.

3 · What it looks like

menu
Before — the menu. Clicking Deploy used to freeze here.
loading screen
The loading screen: checklist, spinner on the active phase, live % + bar.
live mid-bake
A real frame mid-deploy — the bar climbing through the relief bake.
deploy
After — the deploy screen, terrain painted instantly from the warm cache.

4 · How it was verified

Headless capture over CDP (an isolated Chrome so it never fights the editor's browser), with rAF throttling disabled — headless backgrounds the tab and throttles requestAnimationFrame to ~1 Hz, which would corrupt both the timing and the yields. Timing was read page-side through window.__ITM.subscribe, because Node-side polling blocks behind the very main thread the bake is hogging.

MetricBeforeAfter
Feedback after the clicknone — frozen buttonloading screen @ 0.2 ms
Progress updates during the 5.9 s bake0 (frozen)42 samples, 40 distinct % (50→75%)
First deploy frame~5.9 s bake freezeinstant (warm cache)
Determinism (5 seeds)byte-identical
Total click → playable~6.5 s~6.5 s (goal was feedback, not speed)

Standing checks: tsc · build · lint (changed files clean) · smoke (SMOKE OK) · balance (no stalls) all green.

5 · Honest residual

The deploy is now covered by feedback — it is not faster. The ~6 s relief bake is the dominant cost and was deliberately left alone: shrinking it is a real trade-off (bake resolution vs. how crisp the relief stays when you zoom in, or moving the bake off-thread / caching it between sessions). That's its own measured pass, logged as docs/issues/011-deploy-relief-bake-cost.md.

Files: lib/sim/world/create.ts, lib/render/topo.ts, state/store.ts, components/screens/LoadingScreen.tsx, components/GameRoot.tsx, components/world/WorldView.tsx. Numbers from a headless --disable-gpu harness (software canvas); a real GPU browser's bake is faster but still seconds. — In the Mountains