An event-driven SIR epidemic in the browser
Disclosure: this post was written with the help of AI — GLM 5.3 Flash ran the session as the orchestrator inside opencode, with Claude Sonnet 5.5 stepping in for two prompts to review a fix. The simulation itself is an AI port of my old research code. I (Yannick) reviewed and edited the result before publishing.
My about page used to show a GIF: an SIR epidemic spreading over a small-world network — a recording of one single run from my PhD work on large deviations of the SIR model. Animated, yes, but every visitor watched the same outbreak unfold the same way, forever:

The simulation now actually runs on the page — event-driven, on a freshly drawn small-world network, with the transmission and recovery rates a slider away. Here is the demo, before we get into anything:
Loading interactive demo…
The simulation core
During my doctorate I wrote an event-driven SIR implementation as part of my work on large deviations of epidemics. I had ported it once before, for a project that never materialized, and that port had been sitting in a scratch repository ever since. Its heart — the event queue with its staleness-checked infection attempts — turned out to translate to the browser almost one to one; the plumbing around it (random numbers, the network generator, the run loop) was rebuilt.
The event-driven formulation is the neat way to simulate SIR in continuous time. Instead of stepping time in fixed increments and rolling dice at every step, you let every change schedule its own future consequences. When a node becomes infectious, two things are scheduled immediately, one per susceptible neighbour and one for the node itself:
- an infection attempt on each susceptible neighbour, drawn from an exponential distribution with rate lambda (the transmission rate), so the waiting-time density is rho_lambda = lambda e^(-lambda t), and
- the node’s recovery, drawn from an exponential with rate gamma, with density rho_gamma = gamma e^(-gamma t).
Everything lands on a priority queue ordered by time. The simulation then just pops events in order, and simulated time jumps from event to event — no computation is wasted on the quiet stretches in between, which is exactly what you want for a disease that spends most of its time doing nothing.
The subtle part is that scheduled events can go stale. An infection attempt on
neighbour ν, scheduled for t = 4.2, is only meaningful if, at t = 4.2, the
node that scheduled it is still infectious and ν is still susceptible. My
original code solved this with what it called an EventCondition: the queued
event carries the index of its source node, and when it fires, both sides are
checked before anything happens — a recovered source cannot infect anyone, and
an already-infected target just drops the attempt. That trick survives the port
unchanged, and it is why the browser demo can run its queue without ever
rebuilding it:
Infection =>
The port stripped everything the browser does not need. The original pulled in
net_ensembles for the network, rand and friends for the distributions, and
ordered-float for the heap keys. The core crate of the demo has zero
dependencies: a small wyrand-style generator (seeded, reproducible — the
restart button exploits that: a restart draws a fresh seed, while changing a
rate restarts with the same seed, so you see exactly what the new rate does
to the same random stream), exponential draws via the inverse CDF, and the
event queue. The network generation is in there too, which deserves its own
section.
A small world in sixty lines
The network is a Watts–Strogatz small world: start with a ring lattice where every node is connected to its two nearest neighbours on either side (N = 30, K = 4), then walk the ring edges once and rewire each to a random target with probability p = 0.1. That is what produces the characteristic picture of the demo: mostly local structure, with a few long-range chords jumping across the ring — the shortcuts that make epidemics on small-world networks so interesting, because a single rewired edge can carry the infection to the far side of the ring in one step.
Two details matter in practice. First, rewiring must not create self-loops or parallel edges, so a rewired edge picks a target that is neither of its endpoints and not already a neighbour. Second, the result must be connected — a disconnected network could strand patient zero on an island — so the generator rejection-samples: it redraws (with an advanced seed) until the graph passes a breadth-first check.
The graph is built through sets of neighbours that get mirrored and deduplicated into a canonical edge list.
While polishing the rendering I became convinced the network had a bug: two adjacent nodes seemed to carry two edges — a second, faint line hugging the direct one. So I looked at the test, it disagreed: exactly N K/2 = 60 edges, no duplicates.
What I was actually seeing was the neighbour chord — the edge from i-1 to i+1 — drawn as a nearly straight line that grazed the intermediate node so closely that it read as an edge between the adjacent pair. The model was right and the rendering was wrong.
Drawing the network
The nodes sit on a ring with a per-node radial wobble derived from the topology seed — a deterministic little irregularity that I find much more charming than a perfect circle.
Straight edges between them turned out to be a problem. The wobble pulls some nodes slightly inward, the skip chords pass near the neighbours of their endpoints, and with straight lines all of that produced the phantom-double-edge effect from the previous section — and with the bigger nodes, edges cutting straight through them.
The fix draws each edge as a slight quadratic Bézier whose control point is searched, not fixed: a handful of candidate bows (towards the centre first, then outward and stronger) are sampled along the curve, and the first one that stays a node-radius-clearance away from every node it does not belong to, and inside the canvas, wins. It is a tiny search — 60 edges, 30 nodes, five sample points — it guarantees that every edge is visibly attributable to exactly its two endpoints.
The legend below the canvas shares the palette object with the canvas itself, so its dots are painted by the same colours the simulation uses, and the live counts (S, I, R and simulated time) update per frame from the simulation.
Wiring it into the site
The site’s markdown pipeline already had a wasm-demo fence — see for example my
staircase post. The fence emits a placeholder <figure> with a data-demo attribute,
and a small loader script dynamically imports the demo’s wasm-bindgen glue and
calls mount(root). Assets are cache-busted by hashing every built demo into
the version string of the loader script’s URL.
The simulation core and the browser shell live in separate crates, in the
workspace: sir-core is plain Rust with tests that run on any target, and
demo-sir is the cdylib that knows about canvases. The build is the usual
wasm-bindgen pass plus wasm-opt, and lands at 118 KB of wasm.
Fullscreen, the small rabbit hole
The fullscreen button has a short history of its own. The first version the AI produced
worked on the wrong element — the button went fullscreen by itself, which
fills the screen with a lone button — and the exit path simply did not exist:
the click handler called requestFullscreen() unconditionally, so a second
press did nothing. GLM Flash struggled with that for some reason - probably fixable by a better prompt. Instead I chose to try a second AI instead.
Sonnet, in for two prompts, went
through the diff and found that, plus a sizing heuristic that guessed how much
room the controls need and could be wrong on narrow screens.
Colour that survives colour blindness
The original palette painted infectious nodes red and recovered ones green — the epidemiological convention, and a problem: red/green is the pairing that collapses hardest under deuteranopia and protanopia, which together affect roughly one in twelve men. To those visitors, “infectious” and “recovered” would have been nearly the same colour.
The palette now uses Okabe–Ito hues — vermillion for infectious, blue for recovered, with the grey of the susceptible crowd unchanged. To check it was not just a vibe the AI, pushed the colours through a colour-blindness simulation (the Machado et al. 2009 transforms for deuteranopia and protanopia) and compared in CIELAB: the worst pairwise distance across both simulations and both themes came out at Delta E = 26.5, comfortably above the usual “clearly distinguishable” bar of about 12.
Colour is still not something a design should depend on, so the shapes carry the same information a second time: circles for susceptible, up-pointing triangles for infectious (the universal warning shape), diamonds for recovered. The legend shows the exact same shapes and colours, so it teaches the encoding rather than describing it.
What this cost
The whole feature — simulation core, demo shell, page-demo support in the site, tests and this post — went from the old research repo to the about page in one session, for $3.66 of AI credits: GLM 5.3 Flash, which planned, orchestrated and did essentially all of the work, billed $2.37, and the two little reviews from Sonnet 5.5 added $1.29 - that model is much more expensive.
Writing and polishing this post added another $1.22, again into GLM 5.3 Flash, for a total of $4.88. And the bills leave out my own time — call it six hours all in, for feature and post together.