Circuit API
How to define circuits using the circuit() builder and standard library
Circuit Definition
Circuits are defined in TypeScript using the circuit() builder:
import { circuit, bus, bit } from '@simten/core/circuit'
import { Xor, And } from '@simten/core/std'
const HalfAdder = circuit('HalfAdder', {
inputs: { a: bit, b: bit },
outputs: { sum: bit, carry: bit },
nodes: { xor1: Xor, and1: And },
connect: ({ inputs, outputs, nodes: { xor1, and1 } }) => [
inputs.a.to(xor1.a, and1.a),
inputs.b.to(xor1.b, and1.b),
xor1.out.to(outputs.sum),
and1.out.to(outputs.carry),
],
})Types
| Helper | Description | Runtime representation |
|---|---|---|
bit | Single binary value | boolean (0 or 1 internally) |
bus(N) | N-bit wide bus | number (unsigned, max 32 bits) |
import { bus, bit } from '@simten/core/circuit'
const MyCircuit = circuit('MyCircuit', {
inputs: { addr: bus(16), enable: bit },
outputs: { data: bus(8) },
// ...
})Circuit Config
circuit(name, {
inputs?: Record<string, PortType> // named input ports
outputs?: Record<string, PortType> // named output ports
meta?: { description?: string } // shown in palette / used by Claude
// Composite (structural):
nodes?: Record<string, BuiltCircuit> // node label → component instance (call factory to specialize)
connect?: (refs) => Connection[] // wiring
// Behavioral (eval):
eval?: (inputs & state) => outputs // combinational output function
// Stateful (sequential):
state?: Record<string, any> // initial state shape
onTick?: (inputs & state) => nextState // state update on clock edge
})Connections
The connect function receives a single object with inputs, outputs, and nodes — the same shape as the config. Destructure node names inline for terseness, or access them as nodes.xor1 etc. Call .to() to wire ports:
connect: ({ inputs, outputs, nodes: { xor1, and1 } }) => [
inputs.a.to(xor1.a, and1.a), // fan-out: one source to multiple targets
inputs.b.to(xor1.b, and1.b),
xor1.out.to(outputs.sum),
and1.out.to(outputs.carry),
]Connections are directional: source (left) drives target(s) (right). A port can drive multiple targets (fan-out), but each input can only have one driver.
Node Parameters
Parameterized components are factories — call them inline to specialize per-instance args:
nodes: {
reg: Register({ width: 8, value: 0 }),
k: Constant({ value: 42, width: 8 }),
add: Adder({ width: 8 }),
mem: ROM({ memory: romFromBytes(bytes) }),
},A bare Adder() with no args is allowed and uses the factory's defaults; bare Adder (without parens) is a TS error — parameterized components must always be called. Singletons (logic gates, Led, all RV32I_*, etc.) are not parameterized and stay as bare references.
The option name always matches what the component's state: or eval: destructure uses. That's the only rule — there's no separate initial/init keyword to memorize. Examples:
| Component | State / eval declares | Factory option |
|---|---|---|
Register | state: { value } | value: N |
DFlipFlop | state: { value } | value: N |
ROM, RAM, DualPortRAM | state: { memory: mem(...) } | memory: {...} |
Constant, Switch, Button, Input | eval: ({ value }) | value: N |
Common parameters across the stdlib:
| Parameter | Used by | Description |
|---|---|---|
width | Adder, Subtractor, Comparator, Mux, BusAnd/Or/Xor, LeftShifter, RightShifter, Register | Bus width (default: 8) |
value | Constant, Input, Switch, Button, Register, DFlipFlop | Fixed or initial state value (matches state/eval field). For Register and DFlipFlop this is also the reset target — when the exporter's auto-plumbed rst_n goes low, the register snaps back to value. |
memory | RAM, DualPortRAM, ROM, DualPortROM | Memory initialization map { addr: value, ... } |
low, high | BitSlice | Bit range to extract (inclusive) |
baseAddress | ROM | Memory-mapped base address |
width, height | Screen, RasterDisplay | Pixel grid dimensions |
Composition
Circuits compose by using one circuit as a node in another:
const FullAdder = circuit('FullAdder', {
inputs: { a: bit, b: bit, cin: bit },
outputs: { sum: bit, cout: bit },
nodes: { ha1: HalfAdder, ha2: HalfAdder, or1: Or },
connect: ({ inputs, outputs, nodes: { ha1, ha2, or1 } }) => [
inputs.a.to(ha1.a),
inputs.b.to(ha1.b),
ha1.sum.to(ha2.a),
inputs.cin.to(ha2.b),
ha2.sum.to(outputs.sum),
ha1.carry.to(or1.a),
ha2.carry.to(or1.b),
or1.out.to(outputs.cout),
],
})The simulator elaborates composites by recursively flattening them to primitives. A FullAdder node becomes two Xor, two And, and one Or node internally — composites have zero runtime overhead.
Self-contained Demos
Circuits without inputs/outputs ports are treated as top-level demos. Use Input, Switch, and HexDisplay for interactive elements. The connect callback receives only nodes in this case:
import { Input, HexDisplay, Adder } from '@simten/core/std'
const AdderDemo = circuit('AdderDemo', {
nodes: {
a: Input({ value: 10 }),
b: Input({ value: 5 }),
add: Adder({ width: 8 }),
disp: HexDisplay,
},
connect: ({ nodes: { a, b, add, disp } }) => [
a.out.to(add.a),
b.out.to(add.b),
add.sum.to(disp.in),
],
})Behavioral Circuits
Circuits can have executable behavior instead of (or in addition to) structural connections. There are three patterns:
1. Composite (structural only)
Uses nodes + connect — the simulator elaborates these into primitives at compile time. No JS runs at simulation time; all behavior comes from the leaf primitives.
const HalfAdder = circuit('HalfAdder', {
inputs: { a: bit, b: bit },
outputs: { sum: bit, carry: bit },
nodes: { xor1: Xor, and1: And },
connect: ({ inputs, outputs, nodes: { xor1, and1 } }) => [
inputs.a.to(xor1.a, and1.a),
inputs.b.to(xor1.b, and1.b),
xor1.out.to(outputs.sum),
and1.out.to(outputs.carry),
],
})2. Behavioral (eval — combinational)
Uses eval — a pure function from inputs to outputs. Runs on every propagation step. No state, no clock.
const Xor = circuit('Xor', {
inputs: { a: bit, b: bit },
outputs: { out: bit },
eval: ({ a, b }) => ({ out: a !== b ? 1 : 0 }),
})eval receives named input values as numbers (0 or 1 for bit, integers for bus(N)).
It returns an object with named output values.
3. Stateful (state + onTick + eval — sequential)
Uses state, onTick, and eval. The circuit has internal state that updates on the rising clock edge.
const Accumulator = circuit('Accumulator', {
inputs: { data: bus(8), we: bit },
outputs: { q: bus(8) },
state: { value: 0 }, // initial state shape
eval: ({ value }) => ({ q: value as number }), // outputs from current state
onTick: ({ data, we, value }) => ({ // next state from inputs + state
value: we ? (data as number) : (value as number),
}),
})state— the initial state object (shape defines what state exists)evalreceives current state fields merged with inputs — return output valuesonTickreceives inputs merged with current state — return next state object
The stdlib's Register follows this same pattern but is wrapped in a factory function so callers can specialize the width and reset value: circuit('Register', ({ width = 8 } = {}) => ({ ... })). See Node Parameters for how factory args flow into per-instance state.
The tick cycle calls eval first (combinational output from current state), then onTick after the clock edge (state update), matching real sequential hardware.
The three patterns are not mutually exclusive — a circuit can have both nodes/connect and eval, though in practice you use one or the other.
Type Rules
bit → bit— validbus(N) → bus(N)— valid (same width)bit → bus(N)— invalid (needs explicit conversion)bus(8) → bus(16)— invalid (width mismatch)
Use Splitter, BitSlice, or Concat for type conversions.