Crucible
Crucible is a browser-based system design simulator. The point is not to draw architectures: it is to run them. Pull a Source, a Load Balancer, two Services, a Cache, and a Database onto the canvas, wire them up, set 5000 rps, hit Run, and watch a real Go simulation engine raise per-node throughput, p50/p99 latency, in-flight count, queue depth, and error rate in real time. Kill a node mid-run and see the upstream error rate climb. Slow another and watch tail latency stretch. Built for system-design interview prep, architecture validation, and teaching distributed systems primitives.
The heart of the project is a hybrid scheduler with two clocks. The sim clock is event-driven via a min-heap (`sim/engine/heap.go`); it jumps to the next event time and never spins on idle ticks. The render clock runs at a fixed ~33 ms cadence in the Web Worker, snapshots metrics, and posts to the main thread. `Sim.Step(budgetSimNs, maxEvents)` advances events until the sim clock exceeds `now + budgetSimNs` or until `maxEvents` are processed, whichever comes first. The Worker passes a 12 ms wall-time budget per tick; Go multiplies it by `speed` to get the sim budget. Two ceilings, two failure modes covered: runaway producer (event cap), runaway sim time (wall cap). Determinism is preserved by a PCG32 RNG seeded once, a min-heap ordered by `(time, seq)`, and an integer nanosecond clock, so same seed + same topology = identical run, byte-for-byte. Share-replay falls out for free.
The engine ships six node archetypes (`source`, `loadbalancer`, `service`, `cache`, `database`, `queue`), and the frontend catalog maps 23 UI kinds onto them via an `engineKind` field — every component implements a small `Node` interface (`OnRequest`, `OnEvent`, `SetFaulted`, `Snapshot`). The Source produces Poisson arrivals at configurable RPS; the LoadBalancer offers round-robin / least-in-flight / random with downstream fault filtering; the Service has capacity, queue limit, and log-normal service time, propagating downstream failures back up the err_rate chain. Nodes and edges can be hot-added while the sim is running. Latency is a 512-sample ring buffer per node with percentile via insertion-sort copy (smaller in TinyGo than `sort.Slice` and cache-friendly on mostly-sorted input). Throughput uses 10 × 100 ms sliding buckets.
The frontend is SvelteKit 2 + Svelte 5 (runes mode), TypeScript, Tailwind, and `@xyflow/svelte` for the node-graph canvas. State lives in runes stores (`design` for canvas state, `sim` for the worker bridge). The Inspector shows live metrics, chaos buttons, and topology-lint anti-pattern chips (9 rules diagnosing worst-p99 root cause, missing caches, single-points-of-failure). Per-node cost ($/mo) and SLO chips (p99 budget) sit on every component. 13 starter templates (gateway-services, microservices fan-out, CQRS, read-through cache, fan-in queue, …) load in one click; 6 scripted drills run chaos sequences for teaching. Topologies export and import as JSON, diffable and replay-able.
The contract between Go and JS is intentionally tiny. Go exports a global `crucible` to JS with `load`, `step`, `snapshot`, `setSpeed`, `setRPS`, `injectFault`, and `reset`. The whole bridge is one Worker, one WASM module, one binary serialization (JSON for now). TinyGo is chosen over standard Go for a ~10× smaller bundle (~200 KB vs ~2 MB), reflect-free code paths only; `make sim-go` falls back to standard Go if a stdlib feature is missing. No SSR (`ssr = false` in `+layout.ts`), single-page app, `adapter-static`, CDN-only bundle on Vercel. COOP/COEP headers are set in `vite.config.ts` so `SharedArrayBuffer` is available when the time comes to move to multi-threaded WASM.
The bits that earned the diagram
The sim clock is event-driven via a min-heap (sim/engine/heap.go): it jumps to the next event time and never spins on idle ticks. The render clock runs at
a fixed ~33 ms cadence in the Web Worker, snapshots metrics, and posts to
the main thread.
Sim.Step(budgetSimNs, maxEvents) advances events until the sim clock exceeds now + budgetSimNs or maxEvents are processed — whichever first.
The Worker passes a 12 ms wall-time budget per tick; Go multiplies it by speed to get the sim budget. Max speed = giant sim budget, wall cap
stops it. Two ceilings cover two failure modes: runaway producer (event cap), runaway sim
time (wall cap).
PCG32 RNG seeded once, min-heap ordered by (time, seq),
integer-nanosecond clock. Same seed + same topology = identical byte-for-byte run, every
time. Share-replay is the first feature, not the last one bolted on.
The engine ships six archetypes (source · loadbalancer · service · cache · database ·
queue), all behind one tiny Node interface
(OnRequest, OnEvent, SetFaulted, Snapshot). The frontend catalog maps 23 UI kinds onto them via an engineKind field, so adding a CDN or a WAF is a palette entry plus a node prop, not
a new engine type.
Nodes and edges can be hot-added while the sim is running. Latency uses a 512-sample ring
buffer with insertion-sort percentile (smaller in TinyGo than sort.Slice and
cache-friendly on mostly-sorted input). Throughput is a 10 × 100 ms sliding bucket window.
Go exports a global crucible to JS with seven functions: load · step · snapshot · setSpeed · setRPS · injectFault · reset. One Worker, one
module, one JSON serialization. TinyGo over standard Go for a ~10× smaller
bundle (~200 KB sim.wasm), reflect-free code paths only. make sim-go falls back to standard Go if a stdlib feature is ever missing.
ssr = false in +layout.ts, adapter-static, deployed
straight to Vercel as a CDN-only bundle. The sim runs entirely in the visitor's browser —
no API, no usage cost per user, infinite horizontal scaling for free. COOP/COEP headers are set in vite.config.ts so SharedArrayBuffer is available the day the engine moves to multi-threaded WASM.