Simten

Architecture

How circuits go from text to simulation

Why there's a sandbox

User-defined circuit code is arbitrary TypeScript — circuit('Foo', { eval: ({ a, b }) => ... }). Running it in the main React frame would let a malicious circuit read auth cookies, hijack the chat session, or escape into the editor's tab. Instead, every circuit you compile or simulate runs inside a cross-origin iframe (sandbox.simten.dev) with sandbox="allow-scripts" — the browser kernel blocks all network access and origin isolation prevents cookie/DOM reach.

This is the same pattern CodePen uses to isolate user-authored snippets from their editor app — preview runs on a separate origin (cdpn.io) inside a sandboxed iframe, talking to the host page via postMessage only. Everything you'll see called "sandbox" in the Simten codebase is one side of that bridge or the other.

Runtime topology

The four layers a circuit flows through in the browser:

LayerFileResponsibility
Public hookpackages/embed/src/hooks/useCircuitSimulator.tsWhat every embed/blog/editor consumer calls. tick(), setNode(), stepBack(), time-travel, auto-run.
Sandbox bridge (host)packages/ui/src/sandbox/useSandbox.tspostMessage transport + slot lifecycle. Used internally by useCircuitSimulator; only advanced consumers (editor, web component) touch it directly.
Sandbox iframeapps/sandbox/src/main.tsThe other side of the bridge. Runs inside the iframe (cross-origin). Receives postMessage commands, evaluates user code in a Web Worker, drives the engine.
Simulator enginepackages/core/src/simulator/Pure computation — no iframe, no React. Used by the iframe app for browser sims; used directly by @simten/core/sim for Node/CI scripts.

Direction of calls (browser path):

useCircuitSimulator → useSandbox → [postMessage / iframe boundary] → apps/sandbox/main.ts → core/simulator

If you're writing a vitest property test or a CI fuzzer, you skip the bridge entirely — just import from @simten/core/sim and drive the engine directly. See "Two ways to run a circuit" below.

From TypeScript to Simulation

When you define a circuit, it goes through six stages before anything appears on screen:

1

Build

circuit() calls → Circuit IR

The TypeScript circuit() builder collects node types, port declarations, and connection calls. The .to() wiring calls produce a declarative connection list. The result is a BuiltCircuit — a plain data structure describing the circuit's structure.

2

Validate

Check for errors before running

Four validation phases run in order: syntax checks, semantic checks (duplicate names, unknown references), type checks (bus width matching), and structural checks (cycle detection via Tarjan's SCC, floating ports).

3

Compile to IR

AST → Circuit objects

The AST is compiled into Circuit objects — the system's universal representation. Both primitives and composites share the same interface: ports, connections, nodes, parameters.

4

Elaborate

Flatten composites to primitives

Composite circuits are recursively expanded. A FullAdder becomes two Xor, two And, and one Or. Connections are stitched through composite boundaries. The result is a FlatCircuit — only primitives, no hierarchy.

5

Compile to numeric

Typed arrays for fast simulation

Node IDs become array indices. Port connections become index lookups. Each primitive gets mapped to an evaluator function via a dispatch table. The result is a NumericCircuit — zero string operations in the hot path.

6

Simulate

Event-driven propagation

An event queue drives evaluation. When a node's output changes, its dependents are enqueued. Propagation continues until no more changes occur. For sequential circuits, a 5-phase tick cycle handles clock edges and state updates.

See the Simulator Engine page for deep details on the numeric compilation and tick cycle.

Circuit IR

Everything in the system — primitives, user circuits, elaborated results — uses the same Circuit interface:

interface Circuit {
  name: string;
  inputs: PortDescriptor[];
  outputs: PortDescriptor[];
  clocks: ClockDescriptor[];
  state: StateBlock[];
  nodes: Node[];
  connections: Connection[];
  implementation: { kind: 'primitive' } | { kind: 'composite' };
}

Primitives have evaluator functions registered against them. Composites are purely structural — they define what to connect, not how to compute. The elaboration step removes all composites, leaving only primitives.

Simulation Model

Combinational circuits

No clock, no state. Inputs change → outputs update immediately.

  1. Seed the event queue with source nodes (constants, switches, inputs)
  2. Evaluate each node, detect output changes, enqueue dependents
  3. Repeat until the queue is empty (the circuit has stabilized)

Sequential circuits

Each clock tick executes five phases:

Phase 1
Propagate

Evaluate all combinational logic with current state. Capture port values — this is what the API returns.

Phase 2
Clock

Set all clocks to rising edge.

Phase 3
Compute next state

For each sequential node, compute what the next state should be based on current inputs and clock edges.

Phase 4
Commit

Atomically copy next state → current state for all nodes. Increment cycle count. All updates appear simultaneous.

Phase 5
Re-propagate

Propagate the committed state through combinational logic. Prepares internal values for the next tick.

The atomic commit in Phase 4 is what makes this match real hardware — all flip-flops and registers update at the same instant, just like a physical clock edge.

Simulation Session

The SimulationSession is the orchestration layer between the UI and the engine. It wraps a SimulatorEngine and adds:

┌──────────────────────┐
│     React UI         │  hooks, components
└─────────┬────────────┘
          │ subscribe / commands

┌──────────────────────┐
│  SimulationSession   │  history, time-travel, auto-run, batching
└─────────┬────────────┘
          │ tick / restore / setNode

┌──────────────────────┐
│   SimulatorEngine    │  pure computation (no React, no UI)
└──────────────────────┘

Time-travel

Every tick() records a snapshot. Users can step back and forward through the history to inspect any clock cycle. The session maintains a ring buffer of snapshots (default 1000) with index-based navigation:

  • stepBack() / stepForward() — move one cycle
  • seek(index) — jump to any recorded cycle
  • Ticking while viewing the past truncates forward history (linear, no branching)

Environmental state (switch positions, input values) is captured as opaque metadata in each snapshot and restored via callbacks when time-travelling.

Auto-run

For continuous execution, startAutoRun(ticksPerSecond, { displayRate }) runs the engine in a tight loop and only notifies React at the display rate. The RISC-V CPU can run at ~20,000 ticks/sec while the UI updates at 30fps — the engine does ~667 ticks per display frame.

setNode

engine.setNode(name, value) is the unified API for setting any node's value. The engine dispatches based on node type:

  • Switch/Input/Button → sets arguments.value (combinational)
  • ROM/RAM/Register → sets sequential state directly

This means loading a binary into ROM works the same as flipping a switch — one API call, no session reset. ROM data persists across reset (like real hardware — flash survives power cycle).

Packages

@simten/core       ← headless: circuit builder, compiler, simulator, session

@simten/ui         ← visual: nodes, canvas, simulation hooks, editor

@simten/embed      ← convenience: CircuitViewer, CircuitEmbed, web component
PackageDescription
@simten/coreTypeScript builder, compiler, validator, simulator engine, SimulationSession, environmental state, Verilog export. No UI dependencies.
@simten/uiReact components — canonical node renderers, shared canvas, ELK layout, sandbox context (useSandbox), ClockControls, editor UI.
@simten/embedCircuitViewer (simulation + canvas), CircuitEmbed (viewer + auto-harness + info bar), <circuit-embed> web component. Depends on core + ui.
@simten/mcpMCP server for Claude Code integration

Hooks

A single hook, useCircuitSimulator, handles the simulation lifecycle for every entry point (editor, embeds, web component, drill-down). It delegates compilation and execution to the sandbox iframe via useSandbox, so no user code runs on the main thread.

HookInputPurposeUsed by
useCircuitSimulator(builtCircuit)BuiltCircuitBuilds library from dependencies, optional auto-harness, compiles + ticks via sandboxEditorWorkspace, CircuitViewerCircuitEmbed → landing page, blog demos, drill-down

Each consumer gets an isolated sandbox slot (Map<slotId, SlotState>) so simulation state never leaks across embeds or between the editor and a preview.

Data flows one direction — from the compiled circuit to the sandbox to the UI:

circuit() or TS Editor compile → BuiltCircuit

                              useCircuitSimulator → sandbox iframe

                            { portValues, tick, reset, history, ... }

                                CircuitCanvas (rendering)

Editor State Management

The /editor page uses Zustand with Immer middleware for state that multiple sibling components need to share. Three stores handle the compilation→simulation→rendering pipeline:

StoreWhat it holdsWritten byRead by
useCircuitPreviewStoreList of compiled Circuit objects from the last successful compilationMonaco onCompileSuccess callbackCircuit selector UI
useCircuitStoreThe currently active Circuit (selected from the preview list)Preview store / circuit selectorCanvas, ChatPanel, Verilog export, MCP state callbacks
useCircuitLibraryStoreThe CircuitLibrary that resolves component names to definitionsMonaco onCompileSuccess callbackCanvas, ChatPanel, Verilog export

The data flows in one direction:

Monaco compiles


useCircuitPreviewStore  (all compiled circuits)


useCircuitStore         (the active circuit)

     ├──→ useCircuitSimulator(circuit) → sandbox-backed simulation state
     ├──→ ChatPanel (reads circuit for AI context)
     ├──→ Verilog export (reads circuit + library)
     └──→ MCP callbacks (reads circuit for state responses)

Why stores instead of props? The editor page has multiple sibling consumers — Monaco, the circuit canvas, the AI chat panel, the Verilog export button, and MCP callbacks all need the current circuit and library. Threading this through props would mean EditorWorkspace becomes a giant prop-drilling intermediary. Zustand gives each consumer direct access with fine-grained subscriptions (a store update only re-renders components that read the changed slice).

Why not stores in embeds? Embeds (CircuitEmbed, CircuitViewer) are props-driven — they receive a BuiltCircuit and manage simulation internally. They don't need shared state because there's only one consumer (the canvas). This keeps embeds self-contained and avoids leaking store state between multiple <CircuitEmbed> instances on the same page.

Import paths

ImportContents
@simten/core/simulatorSimulationSession, SimulatorEngine, PRIMITIVE_DEFINITIONS, PRIMITIVES, createSimulatorFromCircuit, captureEnvironmentalState, restoreEnvironmentalState
@simten/embedCircuitViewer, CircuitEmbed, <circuit-embed> web component — everything needed to embed a circuit
@simten/ui/canvasCircuitCanvas, ClockControls — shared simulation UI
@simten/ui/sandboxuseSandbox, SandboxProvider — sandbox iframe context used by useCircuitSimulator
@simten/ui/nodesBaseNode, InputNode, OutputNode, LogicGateNode, NodeData — canonical node components (props-driven, store-free)

Node components live in @simten/ui/nodes and are the single source of truth — both the embed and the editor use them.

Two ways to run a circuit

The same engine powers two distinct consumption paths. The split is interactive vs programmatic, not "prototyping vs production":

PathWhere it runsHas a canvas?Drives the canvas?Typical use
SandboxBrowser worker (editor, <circuit-embed>, web component)yesonly via human clicks or MCP actionslive editing, interactive embeds, LLM-driven design
ScriptNode (tsx, vitest, CI)non/a — there is noneproperty tests, fuzzers, batch sweeps, CI verification

Sandbox path

When user code runs inside the editor or an embed, it goes through the sandbox iframe + worker (see Security). Code in this path defines circuits; something else drives them — a human flipping a switch, or Claude calling MCP actions like RUN_SIMULATION / SET_INPUT. User code itself currently has no API to drive the canvas simulator. That's intentional: the canvas sim and any user-code sim run on different threads, so a user-code simulate(...) call would create a parallel-universe instance instead of moving the canvas. (Tracked in issue #51 as a prereq for self-animating embeds.)

Script path

Outside the browser there's no sandbox, no canvas, and no boundary to route through. Import @simten/core directly:

import { circuit, bit } from '@simten/core';
import { createSimulator } from '@simten/core/simulator';

const Counter = circuit('Counter', { ... });
const sim = createSimulator(Counter);
sim.setNode('reset', 1);
sim.tick();
// run property tests, fuzzers, sweeps — full engine speed

This is the path for any code that needs to drive simulation programmatically — whether that's a vitest property test you're writing during prototyping or a CI fuzzer comparing the TS sim to iverilog. There's nothing for it to be out of sync with, so the constraints that apply in the browser don't apply here.

A blog-post <circuit-embed> is "production" but uses the sandbox path. A throwaway vitest exploration is "prototyping" but uses the script path. The axis that matters is whether there's a canvas and whether human/LLM input drives it — not how finished the code is.

MCP Integration

The MCP server drives a viewer in the browser: Claude Code calls MCP tools, and the server pushes the results over a WebSocket for display and in-browser simulation. The browser is display-only — it sends back nothing but a render acknowledgment.

Claude Code (terminal)
↕ MCP tools (stdio)
MCP Server (localhost)
↓ WebSocket push
Browser App
DirectionMechanismExamples
Claude → BrowserWebSocket pushCircuit updates, waveforms, test results
Browser → ClaudeRender acknowledgmentBoolean ack that a pushed circuit rendered

The browser viewer is intentionally one-way: it accepts nothing actionable back, so a rendered page can't drive Claude or read its state. The MCP server provides tools, not inference — users bring their own Claude subscription. Zero AI API cost for the app developer.

See the Claude Code Integration guide for setup and available tools.

On this page