Crucible

Tools
2026-05-24T12:38:47Z
GoTinyGoWASMSvelteKitSvelte 5

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.

  • Hybrid scheduler: event-driven sim clock (min-heap, no idle ticks) + fixed-cadence render clock (~33 ms) in the Web Worker — two ceilings (wall budget, event cap) cover runaway producer and runaway sim time
  • Deterministic by construction: PCG32 RNG seeded once, min-heap ordered by (time, seq), integer-ns clock — same seed + same topology = identical byte-for-byte run, enabling share-replay
  • 23-kind component catalog mapped onto 6 engine archetypes (source · loadbalancer · service · cache · database · queue) via `engineKind`, all behind one `Node` interface
  • Live per-node metrics: throughput, p50/p99 latency, in-flight, queue depth, error rate, sparklines — 512-sample ring buffer with insertion-sort percentile and a 10 × 100 ms sliding throughput window
  • Chaos injection: kill nodes, slow them, drop packets — fault flags propagate on the next OnRequest so the failure mode is visible in the err_rate chain immediately
  • Traffic control across six orders of magnitude (1 → 1,000,000 rps) via a logarithmic slider; speed 0.25× → max (max = giant sim budget, wall cap stops it)
  • Topology lint with 9 anti-pattern rules and worst-p99 diagnosis surfaced as Inspector chips
  • Per-node $/mo cost and p99 SLO budget chips for back-of-envelope architecture economics
  • 13 starter templates (gateway-services, microservices fan-out, CQRS, read-through cache, fan-in queue, …) + 6 scripted chaos drills for teaching distributed systems
  • Topology JSON contract is the single source of truth — serialize the @xyflow/svelte graph, parse with sim/topology/loader.go, export/import/diff/replay
  • Tiny WASM bridge: Go exports a `crucible` global with load · step · snapshot · setSpeed · setRPS · injectFault · reset — one Worker, one module
  • TinyGo over standard Go for ~10× smaller bundle (~200 KB sim.wasm); make sim-go fallback if a stdlib feature is missing
  • No SSR, no server, no backend: `ssr = false` + adapter-static + CDN-only deploy on Vercel — runs entirely in the visitor's browser
  • COOP/COEP headers set in vite.config.ts so SharedArrayBuffer is available for future multi-threaded WASM
  • Hot-add nodes and edges while the sim is running — palette + inspector are live editors, not paused designers
§ Engineering Notes

The bits that earned the diagram

Two clocks, two ceilings

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).

Determinism falls out for free

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.

6 archetypes, 23 UI kinds

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.

A WASM bridge worth keeping tiny

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.

No SSR, no server, no backend

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.