The Animation Is a Replay
Here is the rule this page exists to prove: run the simulation flat out, keep the event log, and treat every animation as a replay of that log. Get that separation right and the engine stops mattering - which I can demonstrate, because this page runs the same factory on four engines across six execution paths: a TypeScript kernel, the same Rust kernel in WebAssembly and native x86-64, SimScript, and SimPy on native CPython and CPython compiled to WebAssembly. Four paths execute in your browser, two behind this site's API. One schema out of all six runs, one scrubber replaying all six, one throughput they all agree on.
The idea is the one from last time, carried one layer further. Last time: the diagram is a projection of the model. This time, applied to motion: the animation is a projection of the event log. A simulation's clock is not your clock. It jumps event to event as fast as the CPU allows - 14 days in about 12 milliseconds - and if you chain the engine to the animation you get those sad simulations that trickle along in real time because someone wanted to watch. Keep the layers apart - model → engine → event log → presentation, nobody reaching past their neighbor - and one run replays at any speed, scrubs, diffs, and renders in any viewer that reads the schema.
Canonical parameters and process rules.
Seed + params + ordered domain events.
The engine can disappear when the run ends. Everything downstream reads the log.
I said there was no library. Here are four engines.
The whole bench hangs off one small contract. Every engine emits this; the stats, the hashes, and the replayer read nothing else:
{ "header": { "engine": "simpy-4.1.2", "seed": 42, "schema": 1, "params": { ... } },
"events": [ { "t": 12.4, "type": "arrive", "id": 3 },
{ "t": 13.1, "type": "code_start", "id": 3 }, ... ] }
The model is the factory from last post, unchanged: arrivals every 25 minutes on average, three coding agents at 45 minutes a pass, CI at 10 minutes failing a quarter of the time, failures looping back as rework, one reviewer at 30 minutes. I can do the first answer on paper: review caps the system at 48 deploys/day. Fig. 2 makes that arithmetic - and the exact point where it stops being true - visible.
per deploy
capacity
48.0/day ceiling
1/(1-p), so coding capacity is 96(1-p) deploys/day. At the default p = 0.25, coding can supply 72/day and review caps throughput at 48/day. At p = 0.50 the capacities are tied; only above 0.50 is coding strictly the constraint.Two hundred SimPy replications put 14-day runs at 47.3 ± 1.9 deploys/day - mean plus or minus run-to-run standard deviation, with a 3σ band from 41.6 to 53.0. Paper prediction, replicated distribution - now run the engines into them.
| Engine | Client payload | Deploys/day | Review util | Queue@14d | Wall ms | Log hash |
|---|
Closed form predicts 48.0/day, review-constrained. Conformance band from 200 SimPy replications: 41.6 – 53.0. Rows marked reference were generated at build time with seed 42; ran here rows executed in your browser; ran on server came back from a native engine through the live API.
Six rows, four hashes - two exact runtime pairs. The four engines draw randomness four different ways (mulberry32, PCG32, SimScript's seeded streams, the Mersenne Twister), so different engines disagree event by event while their statistics agree. Rust/native repeats Rust/WASM's 406362c5… hash across all 5,014 events. SimPy/server repeats SimPy/Pyodide's 3dab9d60… across all 5,276. That is the deployment boundary behaving twice. I expected Rust's native ln() to be the likelier place for a last-bit divergence; seed 42 did not give me one, so the honest claim is empirical and workload-specific, not a promise about every libm.
Two clocks
Look at the wall-ms column again: 14 simulated days, 10 to 200 milliseconds. The virtual clock owes your wristwatch nothing. That asymmetry is the entire case for the event log - you never slow a simulation down to watch it; you run it flat out, keep the log, and pick a replay speed after the fact. The first half of Fig. 4 will replay any row from Fig. 3. The second half lets salabim 26.0.8 run and draw a six-hour version of the factory with its own animation engine. Same process, two presentation architectures; the distinction is visible instead of theoretical.
at this instant
so far
of 0
Opt-in: no salabim or Pillow bytes have loaded.
You could write the kernel in an afternoon
The kernel under all of this is almost insultingly small: a priority queue ordered by (time, sequence), and a loop that pops. Here it is twice - the TypeScript running Fig. 3's inline row and the Rust that became the 47 KB wasm row. Press either toy button: five events, two at the same instant, popped in order - b1 beats b2 because of the sequence number, not luck. I have chased enough flaky behavior to promise you that tie-break is where deterministic engines are won and lost.
function makeKernel() {
const heap = [];
let seq = 0;
const less = (a, b) =>
a.t < b.t ||
(a.t === b.t && a.seq < b.seq);
return {
schedule(t, event) {
heap.push({ t, seq: seq++, event });
// sift up
},
next() {
// pop min by (t, seq), sift down
}
};
}
// The loop:
// while ((n = k.next()) && n.t < horizon)
// handle(n.event) may k.schedule(...)
struct Scheduled<E> { t: f64, seq: u64, event: E }
impl<E> Ord for Scheduled<E> {
fn cmp(&self, o: &Self) -> Ordering {
o.t.total_cmp(&self.t) // earliest first
.then_with(|| o.seq.cmp(&self.seq))
}
}
struct Kernel<E> {
heap: BinaryHeap<Scheduled<E>>,
seq: u64,
}
impl<E> Kernel<E> {
fn schedule(&mut self, t: f64, event: E) {
self.heap.push(Scheduled {
t, seq: self.seq, event });
self.seq += 1;
}
fn next(&mut self) -> Option<(f64, E)> {
self.heap.pop().map(|s| (s.t, s.event))
}
}
Which tool, when
My research notes rank the whole field, but the version worth keeping fits in a tree. And per last post's own rule, the picture is generated: a JSON decision model, serialized to DOT, drawn by the real dot compiled to WebAssembly. The generated source sits under the figure, where it belongs.
Loading viz-js (WASM)…
Generated DOT source
What I'll build
I built all four and ran both Rust and SimPy on both sides of HTTP so this choice could run on data instead of vibes. What the bench told me: my TypeScript kernel took the longest to trust, because the queue-and-rework logic is exactly where the subtle bugs live and every line of it was mine to get wrong. The Rust port was mechanical once the semantics existed - 47 KB in WASM, a persistent shared library on the server, fastest rows on the board. SimScript needed the least adapter code and brought the most opinions. SimPy read closest to the problem statement - the factory is 40 lines of Python that sound like the prose that specified it - and drags the whole scientific ecosystem behind it. So:
- SimPy owns simulation semantics on the server for the software factory, behind a narrow
SimulationRuntimeinterface (dispatch / step / runUntil / reset(seed)) so no framework becomes the domain model. Pyodide keeps the demo self-contained; the native API is the production-shaped path. - The model stays declarative - the same canonical JSON/YAML discipline as the last post; engines consume it, none of them owns it.
- The event-log schema is the contract. Every run ships with seed and params in the header; every viewer, stat, and diff reads logs, never engine state.
- The Rust core is the embedded engine, gated by conformance. The same crate now runs in the browser and behind the API; both paths must reproduce SimPy's statistics against these reference logs before either is trusted - the same conformance-corpus pattern that guards my CrMS charting engine across languages. Fig. 3 is that corpus's first six execution paths.
- Replications before beliefs. Single runs demo; replicated distributions decide. The 200-run band in Fig. 3 came from SimPy in about 20 seconds of CPU.
The quiz at the door
Same rule as last time, which is Geoffrey Litt's rule for his agents before it was mine: no merge until you can pass the quiz. Five questions.
1. Why not stream the engine's events to the browser live, as the simulation produces them?
A DES clock jumps event-to-event at CPU speed. Record the log, then replay it at a human speed - 1 min/s or 1 day/s, the log doesn't care.
2. Two different engines, same scenario, same seed. Should their event-log hashes match? Should their deploys/day?
Determinism is a per-engine property. Across different engines the invariant is statistical agreement, so TypeScript, Rust, SimScript, and SimPy produce four hashes. Fig. 3 runs both Rust and SimPy twice; each browser/server pair repeats its engine's hash because the model, RNG, seed, and serializer are the same.
3. Which layer owns simulation state during a replay?
The engine finished milliseconds after it started, and the renderer is stateless by design - Fig. 4 rebuilds the world from the log on every scrub. The log is the artifact.
4. When does running SimPy in the browser (Pyodide) beat running it behind an API?
Pyodide is real CPython in WebAssembly: perfect for a static, self-contained page like this one, wrong for big runs, private models, or shared storage - that's when the FastAPI-plus-workers shape wins.
5. All four engines land near the predicted 48/day. What result would have falsified the model instead?
Hashes must differ and wall clocks may differ - neither is evidence. A throughput outside the 3σ band would mean the semantics of some engine's model diverged: a real bug, findable precisely because the band exists.
0 of 5 answered.
What's still open
Three things I have not solved. The number people will quote from this page is still a single row, even though the honest evidence is the replicated distribution behind it - replications-by-default is the fix, and none of the six buttons does it yet. Fig. 2 draws the analytical sweep, but the four-engine bench proves agreement only at p = 0.25; a conformance corpus worth the name runs every adapter across that sweep. And the native API is production-shaped, not production-finished: one 14-day run fits comfortably inside one HTTP request, while 200 replications should become a bounded background job that returns an artifact rather than holding a connection open.
Sources
Ideas and research
- Research notes: "Are there any tools or libraries for running discrete-event simulations in web apps?" (Perplexity, 2026-07-15; three-part conversation) - the browser-DES landscape, the ecosystem map, and the SimPy-in-a-web-app patterns this post demonstrates.
- The Diagram Is Not the Model - the previous post; its Fig. 6 simulation is the model all four engines run here, and its closed-form arithmetic is the reference row. stephens.page/blog/the-diagram-is-not-the-model
- Geoffrey Litt, "Understanding: the New Bottleneck" (talk I attended) - the quiz rule. youtube.com/watch?v=WkBPX-oDMnA
Engines demonstrated
- SimPy 4.1.2 (pure Python, via native CPython 3.12 behind this site's API and Pyodide v0.28 in the browser) - simpy.readthedocs.io, pyodide.org
- SimScript 1.0.37 by Bernardo Castilho - github.com/Bernardo-Castilho/SimScript
- salabim 26.0.8 by Ruud van der Ham (Fig. 4's built-in animation path, executed in Pyodide with Pillow 11.2.1) - salabim animation documentation, this post's model
- replay-kernel 0.1.0 (this post's Rust crate, compiled to WebAssembly with wasm-bindgen/wasm-pack and to a native x86-64 shared library loaded through a narrow C ABI) - source
- The TypeScript kernel - inline in this page; also the previous post's simulation, refactored to emit the log schema.
- highlight.js 11.11.1 (Fig. 5 TypeScript and Rust syntax parsing, loaded from esm.sh) - highlightjs.org
The wider field (researched, not demonstrated)
- Browser-capable: discrete-sim (discrete-sim.dev), OESjs (sim4edu.com/oesjs), SIM.JS (2011-era API, ideas worth reading).
- Python: Ciw (queueing networks, ciw.readthedocs.io).
- Visual platforms: AnyLogic, JaamSim (open-source GUI); industrial: FlexSim, Simio, Arena, SIMUL8, WITNESS.
- Elsewhere: simmer (R), ConcurrentSim.jl (Julia), desru/desim (Rust); domain-specific: ns-3, SUMO, gem5, SystemC.
For AI agents: agents.md carries the event-log schema, verified numbers, adapter rules, native API contract, checklist, and self-test without requiring a browser.