Skip to main content
← All writingKylan Thomson

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.

Wildfire Sentinel An architecture diagram generated by Archify. AWS Terrain Tiles · 30 m DEM · fallback API · Architecture component AWS Terrain Tiles 30 m DEM · fallback API OpenStreetMap · Overpass · 45,930 buildings · Architecture component OpenStreetMap Overpass · 45,930 buildings Open-Meteo ERA5 · hourly wind · RH · rain · Architecture component Open-Meteo ERA5 hourly wind · RH · rain NIFC WFIGS · observed perimeters · Architecture component NIFC WFIGS observed perimeters Scenario loader · parallel fetch · fallbacks · In the browser · localStorage cache · React Scenario loader parallel fetch · fallbacks localStorage cache Drone planner · 5 pluggable doctrines · In the browser Drone planner 5 pluggable doctrines Fire engine · rate of spread · embers · In the browser · seeded RNG Fire engine rate of spread · embers seeded RNG Ensemble worker · Web Worker · Monte Carlo · In the browser · JavaScript Ensemble worker Web Worker · Monte Carlo Operational map · Canvas 2D · Three.js · In the browser · static Vite app on Vercel · React Operational map Canvas 2D · Three.js static Vite app on Vercel Strategy Lab · paired-seed races · In the browser Strategy Lab paired-seed races elevation footprints · water hourly timeline validation perimeter rasterized grid retardant lines cloned engines changed cells ranked outcomes value saved In the browser Legend Frontend Backend External
No server anywhere. A scenario loader fetches four keyless public sources in parallel and rasterises them onto one grid; the fire engine, drone planner, and Strategy Lab run in the browser, and Monte Carlo ensembles are pushed onto a Web Worker so the map keeps animating while they run.

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;
});
useSimulation.js: the constants that define the clock, and the history decimation that keeps the sparkline light.

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;
SimulationOverlay.jsx: the one comparison that keeps the dynamic layer cheap.

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.

  1. Streaming updates never yank a running simulation. A completed load that lands mid-run is parked and applied at the next reset.
  2. Loads are cancellable, and a superseded load is treated as superseded, not as an error.
  3. 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.

Book a 30-min callEmail about this work