Simten

Building custom UIs

Four tiers — from drop-in <CircuitEmbed /> to full composition with useCircuitSimulator + CircuitCanvas.

<CircuitEmbed /> is the default for putting an interactive circuit on a page. When you need more — a sibling that reacts to switch toggles, a custom HUD, a debugger driving the sim — the same primitives are available; you compose them yourself.

The simulator runs in pure JavaScript inside the sandbox iframe, independent of any rendering. Each tier below is a different way for your React UI to subscribe to its state and write inputs back. Pick the highest one that gives you what you need.

Tier 1 — Drop-in <CircuitEmbed />

The default. One component, sensible defaults, identical look across every page on simten.dev. Use this unless you need something it doesn't give you.

import { CircuitEmbed } from '@simten/embed'

<CircuitEmbed
  circuit={MyCircuit}
  title="Half adder"
  description="Two switches → XOR for the sum, AND for the carry."
/>

No state out, no imperative control — just a self-contained interactive embed.

Every page in Examples uses this pattern, as do most inline circuits across the /blog/* posts.

Tier 2 — Read sim state with onPortValuesChange

When you want a sibling component to react to the embed's live state (e.g. a truth-table highlight that follows the user's switch toggles, or an external readout of a specific port value), pass an onPortValuesChange callback. The embed fires it once when the sim first settles, then again on every subsequent settled change.

import { useState } from 'react'
import { CircuitEmbed } from '@simten/embed'
import type { FlatPortValueMap } from '@simten/core/simulator'
import { TruthTable, computeActiveRow } from '@/components/TruthTable'

function HalfAdderWithLiveTable() {
  const [portValues, setPortValues] = useState<FlatPortValueMap | null>(null)
  const activeRow = computeActiveRow(portValues, COLUMNS, ROWS)

  return (
    <>
      <CircuitEmbed
        circuit={HalfAdder}
        title="Half adder"
        onPortValuesChange={setPortValues}
      />
      <TruthTable columns={COLUMNS} rows={ROWS} highlightRow={activeRow} />
    </>
  )
}

Live demo — toggle the switches:

Compiling...
Half adder
Toggle the switches — the truth table row follows.
Half adder truth table
absumcarry
0000
0110
1010
1101

The callback is captured in a ref inside the embed, so inline functions like onPortValuesChange={(pv) => setX(pv)} are safe — they won't cause spurious re-fires across parent renders.

Firing semantics

A few things worth knowing about when the callback fires:

  • First fire — once the embed's internal simulator becomes ready and port values have first settled. Before that, the callback doesn't fire and your useState<FlatPortValueMap | null>(null) stays at null for one paint. Plan for that initial frame in your rendering (e.g. computeActiveRow returns undefined cleanly when port values are absent).
  • Subsequent fires — only on settled (post-propagation) states. The simulator never exposes intermediate propagation states, so there's no risk of flicker from mid-propagation values.
  • Reference identity — the port-values Map is not guaranteed to be a stable reference across no-op ticks. Sequential auto-run loops can emit new Map instances with unchanged contents. If you're doing expensive derived work, memoize on the actual values you care about rather than on the Map reference itself.

The /learn/adders page uses this for its live truth tables — toggle switches on the half-adder or full-adder, watch the matching row in the table light up.

Tier 3 — Write into the sim with the imperative handle

When a sibling needs to drive the embed (e.g. clicking a row in a truth table should set the canvas switches to that combination), use the existing ref handle. No new API required.

import { useRef } from 'react'
import { CircuitEmbed, type CircuitEmbedHandle } from '@simten/embed'

function HalfAdderDrivenByTable() {
  const embedRef = useRef<CircuitEmbedHandle>(null)

  return (
    <>
      <CircuitEmbed ref={embedRef} circuit={HalfAdder} />
      <TruthTable
        ...
        onRowClick={(row) => {
          embedRef.current?.setNodeValue('a', row[0])
          embedRef.current?.setNodeValue('b', row[1])
        }}
      />
    </>
  )
}

Pair tier 2 (read out) and tier 3 (write in) to get bidirectional binding between the embed and any sibling UI you write.

No page on simten.dev currently needs tier 3 — the API is available but nothing's using it yet. The most likely first consumer would be a clickable truth table that drives the embed's switches from the table side.

Tier 4 — Full composition

When you want to break out of the embed's chrome entirely and build your own frontend on top of the simulator, drop down to the underlying primitives. useCircuitSimulator gives you the live state and the actions; render whatever React you want with them. The circuit becomes your engine, your component is the UI.

import { useCircuitSimulator } from '@simten/embed'
import { TruthTable, computeActiveRow } from '@/components/TruthTable'

function HalfAdderWithCustomControls() {
  const sim = useCircuitSimulator(HalfAdder, { autoHarness: true })
  const a = readPortBit(sim.portValues, 'a')
  const b = readPortBit(sim.portValues, 'b')
  const sum = readPortBit(sim.portValues, 'sum')
  const carry = readPortBit(sim.portValues, 'carry')

  return (
    <>
      {/* Custom toggle buttons drive sim.setNodeValue */}
      <ToggleControl value={a} onToggle={() => sim.setNodeValue('a', a ? 0 : 1)} />
      <ToggleControl value={b} onToggle={() => sim.setNodeValue('b', b ? 0 : 1)} />

      {/* Styled LED readouts pull from sim.portValues */}
      <LedReadout lit={sum === 1} label="sum" />
      <LedReadout lit={carry === 1} label="carry" />

      <TruthTable
        columns={COLUMNS}
        rows={ROWS}
        highlightRow={computeActiveRow(sim.portValues, COLUMNS, ROWS)}
      />
    </>
  )
}

Live demo — same HalfAdder circuit as tier 2, but rendered as custom toggle buttons and LED indicators instead of a schematic. No CircuitCanvas anywhere in this tree:

a0
b0
sum
0
carry
0
Half adder truth table
absumcarry
0000
0110
1010
1101

The single useCircuitSimulator(HalfAdder) call creates one simulator instance. Custom UI reads from sim.portValues and writes via sim.setNodeValue; the truth table reads from the same map. One sim, many consumers, all of them yours.

The simulator is still doing real work — compiling, propagating, exposing port values through the sandbox iframe like every other tier. You could equally render nothing at all and use it purely as a background computation engine (a headless test harness, a checksum verifier, an audio-rate signal generator wired to WebAudio).

Tier 4 is the right choice when:

  • The schematic isn't what you want to show. A game frontend, a calculator UI, a hex memory editor, an animated state-machine diagram — anything where the canvas would be the wrong artifact.
  • You want chrome that's meaningfully different from CircuitEmbed's card and you'd rather render <CircuitCanvas> directly with no wrapping (it's also a public export — same composition, with the canvas).
  • You're rendering multiple circuits in a single composed panel where layout coordination matters.
  • You're building a debugger / IDE-like surface that wants to expose sim actions to many sibling UIs.

For most pages, tier 2 is enough.

Two examples of tier 4 on simten.dev itself:

  • Snake — the circuit is the game engine (DualPortRAM framebuffer, 4-phase pipeline, collision detection); the React component renders a custom 8×8 pixel grid + D-pad + speed slider on top of the sim. No CircuitCanvas shown at all — the schematic is invisible to the player. Pure proof that the simulator can drive any frontend.
  • RV32I CPU debugger — a 5-stage pipelined RISC-V processor on the engine side, with a bespoke debugger UI on top: register file, memory view, pipeline-stage indicators, instruction stream. Compile C / Rust / asm in-browser and step through execution cycle by cycle.

Security

Composing the primitives directly doesn't bypass anything — useCircuitSimulator routes every tier's simulation work through the same cross-origin sandbox iframe that <CircuitEmbed /> uses internally. See Security architecture for the full picture (origin isolation, Web Worker thread isolation, CSP, postMessage boundary).

Which tier do I pick?

TierPick whenAPI
1You just want a circuit on a page<CircuitEmbed circuit={X} />
2A sibling reacts to switch toggles<CircuitEmbed onPortValuesChange={...} />
3A sibling drives the switchesref={...} + setNodeValue
4You want fully custom chrome / layoutuseCircuitSimulator + CircuitCanvas

Most production simten.dev pages use tier 1. The /learn/adders half- and full-adder sections use tier 2 (live truth tables). Tier 3 isn't currently used in the public site but the API is there. Tier 4 is the escape hatch — keep it in mind for when you outgrow the canned embed.

On this page