Simulation · Front-end Engineering
Building an AI Wildfire Simulator in React
Wildfire Sentinel runs an entire fire-spread model, drone fleet, and Monte Carlo forecast in the browser. This is the front-end engineering that keeps a 12,100-cell simulation smooth in a React app.
- Published
- Reading time
- 9 minutes
- Project
- Wildfire Sentinel case study
Wildfire Sentinel runs a fire-spread model, a drone fleet, a Monte Carlo forecast, and a head-to-head strategy lab entirely in the browser, on a grid of 12,100 cells, with no backend anywhere. It is a React app. This piece is about the front-end engineering that keeps that simulation smooth: where the state lives, why almost none of it is React state, and how the map paints without redrawing the world every tick.
The physics and the data are covered elsewhere: the case study for the model, the data-layer article for how terrain, buildings, and weather are assembled from open sources. Here I want to stay in the React tree and the canvas.
Who owns what
The root component composes and lays out and owns no simulation logic. Its own state is view state: which tab is open, 2D or 3D, whether the observed perimeter is shown, the forecast horizon. Everything about the fire lives in four hooks, each with one job.
- useSimulation owns the engine, the simulated clock, and the drone fleet in refs, and publishes per-tick state: current weather, render data, impact statistics, a decimated history for the sparkline, the burn snapshot the scorer reads.
- useScenarioLoader owns which scenario is being looked at and how far along loading it is, including an abort controller so a superseded load can never push a stale partial scenario into a caller that has moved on.
- useEnsembleJob owns one Monte Carlo job on the worker, instantiated twice: once for the forecast, once for calibration.
- useObservedPerimeter owns the real fire perimeter from the national incident feed, keyed so that streaming partial scenarios do not re-fetch it.
Refs for the loop, state for the screen
The simulation ticks on an interval: every 100 milliseconds, one tick, and each tick advances the engine by the current speed in simulated hours, one engine step per hour with the drone fleet acting inside that loop. The speed, the active strategy, and the spread multiplier are read by that loop from refs, not from state, so changing them does not tear down and rebuild the interval. The refs are written in effects rather than during render, for a reason the code states: a render can be discarded or replayed, and a ref written during one would keep a stale value.
const SIM_TICK_MS = 100;
const MS_PER_SIM_HOUR = 3600 * 1000;
const STATS_HISTORY_MAX = 400;
const SCORE_EVERY_TICKS = 10;
// ...
setStatsHistory((prev) => {
const next = [...prev, sample];
return next.length > STATS_HISTORY_MAX ? next.filter((_, i) => i % 2 === 0) : next;
});The engine itself hands the renderer its live typed arrays by reference. The state grid is rows of Int8Array, spread progress and burn timers are Float32Array, the burned mask is Uint8Array, and the scenario's fuel, elevation, and hillshade are typed too. There is no per-tick copy of the grid. React re-renders only the overlay component, and the overlay does its work in effects that draw to canvases.
Painting only what changed
The map is six stacked canvases, all absolutely positioned and pointer-inert: base terrain, dynamic cells, forecast risk, observed perimeter, tool animations, wind vectors. Switching to the 3D view pushes the stack behind rather than unmounting it, so switching back is free.
The base layer is drawn once per scenario. It walks the initial grid and fills one circle per cell, coloured by elevation under a hillshade that was baked at load time from a fixed sun position and percentile-normalised so a flat coastal plain and a mountain range both get usable contrast. Its effect dependencies are only the things that change when the scenario changes.
The dynamic layer is cleared and re-walked every tick, but it paints only cells whose state differs from the static baseline. On a 110-column grid that is the fire front, the burned scar, and the treated cells, which is a small fraction of the grid for most of a run. Retardant gets a hatch, a hand line gets a cross, a spent tool leaves a persistent ring, so a glance at the map tells you what was done where.
const s = gridRow[c];
const isStation = stationSet.has(cellKey);
// Skip unchanged cells — the base layer already shows them
if (!isStation && baseRow && s === baseRow[c]) continue;Animation loops that know when to stop
There are exactly two requestAnimationFrame loops. The tool animation loop is self-terminating: it schedules another frame only while some effect is inside its half-second window, so with nothing animating it simply is not running. The drone layer runs one long-lived loop started once on mount that reads its props from a ref written after commit, so it is never torn down and rebuilt per render, and drone positions come from the planner's mission timing rather than being re-derived on the canvas.
The work that leaves the main thread
A damage forecast runs 24 cloned fires 48 hours forward; a strategy race runs five configurations by eight replicates; a calibration sweep replays a 198-hour fire 21 times. None of that can share a thread with a map that is supposed to keep animating. One module Web Worker serves all three jobs: it revives the engine and weather timeline from serialised snapshots, streams progress messages back, and honours cancellation. If the worker cannot boot (an old browser, a locked-down embed), the same three functions run inline on the main thread and the app says so in the console rather than failing.
Two details make the snapshot round trip exact. The random generator is deliberately left out, so every consumer seeds its own and ensemble member N is the same fire for every configuration. And the set of burning cells is serialised in iteration order, because that order decides the order random draws are consumed, and rebuilding it from the grid would produce a different, equally valid, not identical fire.
Loading a place, progressively
A scenario is a centre, a radius, and a date range, and everything is fetched live. The loader fires a partial scenario as soon as elevation and weather are in, with a quick sea-level water mask, so the map is interactive in seconds. Building footprints then stream in by quadrant across mirrored endpoints, each arrival repainting the map and bumping the structure count in the header chip, so the user watches the town fill in. The tooltip on that chip names which source served each layer, which is where the fallbacks become visible: terrain tiles or the elevation API, ERA5 or the bundled weather of last resort.
- Streaming updates never yank a running simulation. A completed load that lands mid-run is parked and applied at the next reset.
- Loads are cancellable, and a superseded load is treated as superseded, not as an error.
- Partial datasets are never cached, so a later visit retries the regions that failed. A complete one is cached in local storage at about 250 kilobytes, with elevations rounded to a tenth of a metre.
The 3D view, on demand
The Three.js terrain is behind a dynamic import, because three.js is about 125 kilobytes gzipped and only the 3D view needs it; users who stay in 2D never download it. The terrain is a single plane geometry with one vertex per grid cell. Each tick, the update effect writes each vertex's height from the elevation grid and its colour from the cell state, flags the attributes dirty, and recomputes normals. Buildings are small boxes reconciled through a map keyed by cell, created and removed rather than rebuilt; wind arrows are memoised on rounded direction and speed so the field rebuilds only when the displayed wind changes. The view is wrapped in its own error boundary whose message says what it means: WebGL is unavailable, and the simulation is still running.
Failing without a blank page
Every data path in the app degrades: terrain tiles fall back to an elevation API, buildings fall back to land-use density, the worker falls back to inline execution, a missing perimeter is reported rather than thrown. The one exception used to be a render-time throw, which unmounted the whole tree. Each panel now sits in an error boundary that tells the user the simulation is unaffected and to switch tabs. The smoke test enforces the same philosophy from the other side: it stubs the network to reject everything and mocks the canvas context to null, and the app has to render anyway.
Continuous integration runs lint, a JSDoc-driven type check, 72 Vitest cases (the simulation suites in a plain Node environment, because booting jsdom for pure computation costs more than the tests), and the build. Deployment is static files: a Vite build on Vercel, or a 50-megabyte nginx image for anyone who wants to run it themselves. A tool for incidents should still work years from now, and static files are the closest thing to a guarantee.
The response layer that sits on top of all this, with its five doctrines and a fleet that has to fly home and reload, has its own article, and the experiment that measures those doctrines is in the piece on paired seeds.