Getting Started

Install the compiler, write your first circuit, and drive it from Node.

This walkthrough takes you from a fresh checkout to a working compiled circuit you can drive from Node. Each step has expected output so you can verify you are on track.

1. Install and verify the CLI

You need Zig 0.15.x. Build the compiler:

zig build circ-compile

The binary lands at zig-out/bin/circ-compile. Verify it works by compiling a fixture from the test suite:

zig-out/bin/circ-compile tests/fixtures/circuits/inverter.circ --inspect | head -8

Expected:

=== Parse Tree ===
File [f0:1:1-3:23]
Imports (0)
Inputs (1)
  Input a [f0:1:7-1:8]
Outputs (1)
  Output out [f0:3:1-3:23]
    NamedRef inv.out [f0:3:15-3:22]

If you see that, the compiler is working.

2. Write your first .circ file

Create examples/inverter.circ:

input a
not inv(in=a)
led l(in=inv.out)
output out(in=inv.out)

This declares one input pin a, drives it through a not gate, mirrors the result on an led (visualisation primitive), and exposes the inverted signal as an output pin. See DOCS/circuit-format.md for the full language reference.

3. Compile it to WebAssembly

zig-out/bin/circ-compile examples/inverter.circ -o examples/inverter.wasm

The CLI runs the parser, validator, and topology serializer end-to-end, then appends the serialized circuit as circ.topology.v0.min and circ.topology.v0.full custom WASM sections to the pre-built runtime blob. No zig subprocess is spawned. On success it writes the combined .wasm to the path given to -o.

Inspect the compiled circuit’s interface:

zig-out/bin/circ-compile examples/inverter.circ --inspect

Or visualise the circuit as an ASCII schematic without producing any artifact:

zig-out/bin/circ-compile examples/inverter.circ --preview

Expected:

╭───╮     ╭───╮     ╭───╮  
│ a ├○───▶┤NOT├○●──▶┤LED│  
╰───╯     ╰───╯ │   ╰───╯  

                │   ╭─────╮
                ╰──▶┤ out │
                    ╰─────╯

The not-gate’s output fans out to both the LED and the out pin — marks the branch point, and the two arrowheads show where each branch terminates. See preview.md for --expand-macros, --color, and the rendering conventions.

Or enumerate the circuit’s behaviour against every input combination as a Markdown truth table:

zig-out/bin/circ-compile examples/inverter.circ --truth-table
| a | out |
|---|-----|
| 0 | 1   |
| 1 | 0   |

--truth-table runs the resolver and validator first; circuits with combinational loops (E008) are rejected before any simulation. The mode caps at 16 inputs (2^16 = 65,536 rows) to avoid accidental blow-up — wider circuits should be exercised through the .wasm runtime instead.

The relevant block tells you which component IDs to drive from JavaScript:

Inputs (1)
  id=0 name=a component=0
Outputs (1)
  id=0 name=out driver=1.out

setPin takes the input pin’s component id (0 for a). getOutputState takes the driver component id of the output (1 here — the not gate that drives out), not the output_pin’s own id.

4. Drive the compiled .wasm from Node

The compiled .wasm contains the runtime and a circ.topology.v0.min custom section. The host must load that section into WASM linear memory before calling init(). Create examples/run.mjs:

import fs from "node:fs";

const bytes = fs.readFileSync(process.argv[2]);
const mod = await WebAssembly.compile(bytes);
const { exports: w } = await WebAssembly.instantiate(mod, {
  env: {
    debugEnabled: () => 0,
    onDebugLog: () => {},
  },
});

// Load the circ.topology.v0.min custom section into WASM linear memory
const [topoSection] = WebAssembly.Module.customSections(mod, "circ.topology.v0.min");
const topoBytes = new Uint8Array(topoSection);
const ptr = w.topology_alloc(topoBytes.length);
new Uint8Array(w.memory.buffer).set(topoBytes, ptr);

w.init();
w.setPin(0, 0); w.run(); console.log("a=0 -> NOT a =", w.getOutputState(1));
w.setPin(0, 1); w.run(); console.log("a=1 -> NOT a =", w.getOutputState(1));

Run it:

node examples/run.mjs examples/inverter.wasm

Expected:

a=0 -> NOT a = 1
a=1 -> NOT a = 0

0 means low, 1 means high, 2 means undefined. The full export list emitted by circ-compile … -o out.wasm today is exactly topology_alloc, init, run, setPin, getOutputState (plus memory); see DOCS/wasm-api.md for the full contract. The two env callbacks (debugEnabled and onDebugLog) are required imports — supply the no-op stubs above unless you want debug logging.

5. Use a built-in macro (xor)

Built-in gates or, nand, nor, xor, and xnor are auto-imported when a file is part of a project — i.e. when the parser sees at least one import declaration. To use a built-in in an otherwise standalone file, add an explicit import to the virtual <builtin>/ filesystem:

// examples/xor_demo.circ
import xor "<builtin>/xor.circ"
input a, b
xor x(a=a, b=b)
output out(in=x.out)

Compile and inspect:

zig-out/bin/circ-compile examples/xor_demo.circ -o examples/xor.wasm
zig-out/bin/circ-compile examples/xor_demo.circ --inspect

v0 papercut. When the file has no user imports, single-file mode does not auto-import built-ins, so a bare xor raises E001: undeclared name 'xor'. The explicit import xor "<builtin>/xor.circ" is the workaround until the CLI is updated to run the project pipeline unconditionally.

Once a file participates in the project pipeline, root-pin component IDs are assigned in a flat layout that includes the built-in macro’s expanded gates. The simplest way to discover them is to scan in JS:

for (let i = 0; i < 64; i++) {
  const v = w.getOutputState(i);
  if (v !== 2) console.log(`id=${i} -> ${v}`);
}

For a structured map, parse the circ.topology.v0.full custom section in JS — it carries per-file IDs, component aliases, and macro provenance. (A future runtime release may surface this through a getFileInfo() export, but it is not present in today’s compiled artifacts.)

6. Compose with a sub-circuit import

Sub-circuits live in their own .circ files and are imported by alias. The half-adder is a canonical two-file project:

// examples/half_adder/half_adder.circ
input a, b
xor s(a=a, b=b)
and c(a=a, b=b)
output sum(in=s.out)
output carry(in=c.out)
// examples/half_adder/root.circ
import half_adder "half_adder.circ"
input a, b
half_adder ha(a=a, b=b)
output sum(in=ha.sum)
output carry(in=ha.carry)

Compile from the root:

zig-out/bin/circ-compile examples/half_adder/root.circ -o examples/half_adder.wasm

Sub-circuits are fully flattened by the serializer into a single ordered sequence of primitive components — no function calls, no hierarchy in the runtime. Loading from JS uses the same topology_alloc + init() pattern as step 4; discovering global IDs in deeper hierarchies is currently easiest via circ-compile --inspect, which prints the resolved IR with each Inputs (...) / Outputs (...) block annotated with component IDs. (The circ.topology.v0.full custom section carries the same information for programmatic readers.)

More worked project fixtures, including a full-adder built from two half-adders and a 4-bit AND/OR network, live under tests/fixtures/projects/ and double as integration tests.

Where to go next

  • DOCS/circuit-format.md — the complete .circ language reference (declarations, ports, anonymous components, built-ins).
  • DOCS/wasm-api.md — every export and import on the compiled artifact, plus the topology custom-section layout.
  • DOCS/architecture.md and DOCS/decisions/ — design rationale, useful when contributing.
  • tests/fixtures/circuits/ and tests/fixtures/projects/ — copy-and-modify templates for common circuit patterns.