How it works
PartForge
A part is either a gate or a circuit made of other parts, so a NAND becomes an Inverter, two Inverters become a robot's brain, and the robot drives on whatever the circuit decides. New to logic gates? Start with the two-minute primer and the tutorial. After those come the four ideas that make it work, each with the few lines of code that carry it.
- Digital logic
- Hierarchical design
- Union-find
- Discrete-time simulation
- Differential drive
From a click to a moving robot
- Drawparts and wires on the board; Input and Output nodes are the pins
- Savethe circuit becomes a part, checked so it can't contain itself
- Flattennested parts are expanded into one flat list of gates
- Tickevery gate reads the last tick's signals and writes the next
- Drivesensors go in, 32 ticks run, motor pins turn the wheels
Logic gates in two minutes
Start here if gates are new to you
Every wire on the board carries one bit: on or off, 1 or 0. On the board an on wire is drawn green and an off wire grey. For the robot, a bit is a simple yes-or-no fact. lineL is on when the left sensor is over the black line. motorL is on when the left wheel should spin forward.
A gate is a tiny box with a fixed rule. It looks at the wires coming in on its left and sets the wire going out on its right. That's all a gate does. The rule is usually written as a truth table, which lists every combination of inputs and the output each one gives. Here are the gates in the Parts list:
NOT
Flips its one input: on becomes off, off becomes on.
| in | out |
|---|---|
| 0 | 1 |
| 1 | 0 |
Stop the left wheel when the left sensor sees the line.
AND
On only when both inputs are on.
| a | b | out |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 0 |
| 1 | 0 | 0 |
| 1 | 1 | 1 |
Drive only if the path is clear and the battery is fine.
OR
On when either input (or both) is on.
| a | b | out |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 1 |
Back up if the bump sensor or a line sensor fires.
XOR
On when the inputs are different.
| a | b | out |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
Exactly one line sensor sees the line: the car is drifting.
NAND
AND, then flipped: off only when both are on.
| a | b | out |
|---|---|---|
| 0 | 0 | 1 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
The universal gate. Every other gate can be built from NANDs alone.
NOR
OR, then flipped: on only when both are off.
| a | b | out |
|---|---|---|
| 0 | 0 | 1 |
| 0 | 1 | 0 |
| 1 | 0 | 0 |
| 1 | 1 | 0 |
Two NORs wired into each other make the SR Latch, one bit of memory.
HIGH
No inputs. Its output is always on.
| out |
|---|
| 1 |
Wire it to motorR for a wheel that never stops.
Why the example robot follows a line
The Line Follower is just two NOTs (each built from a NAND, which is what the Inverter part is). The left sensor feeds the left motor through one, and the right sensor feeds the right motor through the other. The robot starts straddling the line, so normally neither sensor sees it. Here are the four cases:
| lineL | lineR | motorL | motorR | What the robot does |
|---|---|---|---|---|
| off | off | fwd | fwd | Drives straight. The line is between the sensors, where it should be. |
| ON | off | stop | fwd | It drifted right, so the line is under the left sensor. The left wheel stops and the right one keeps going, which swings the robot left, back over the line. |
| off | ON | fwd | stop | The mirror image: it swings right. |
| ON | ON | stop | stop | Both wheels stop. This doesn't happen on the oval, but it would at a crossing. |
No step in this needs a program. Two flipped wires, applied about sixty times a second, are enough to steer.
Where the Lego idea comes in
Gates snap together the way bricks do. Wire a few gates together, press Save as…, and the circuit becomes a new block in Your parts. It has its own pins and can go inside the next circuit. The Inverter is one NAND, the Line Follower is two Inverters, and a part you build next could use the Line Follower as one of its pieces. In PartForge the bricks are pieces of logic, not motors and wheels. The robot's body is fixed, and what you build is its brain.
Tutorial
About ten minutes, from the example robot to a brain of your own
Open PartForge in a second tab and keep this page beside it. There are three panels: Parts on the left (the bricks), the board in the middle (where you wire them), and Test drive on the right (the robot). A mouse is easiest. On a phone, tap instead of click and use the Edit and Delete buttons instead of double-click and right-click.
-
Watch the example drive
In Test drive, check that Brain says Line Follower, then press Run.
You should see the blue robot lap the oval and leave a gold trail. Under the track,
lineL / lineRblink ON as each sensor brushes the line, and the motor on that side changes to stop at the same moment. That's the table above, running live. -
Flip the inputs yourself
The board shows the Line Follower's wiring. Its inputs are the rounded
lineLandlineRnodes on the left. ClicklineLonce to switch it on. Click it again to switch it off.Set Signal speed (top right of the board) to slow if you want to watch the change travel from gate to gate.
You should see the wire out of
lineLturn green and the wire intomotorLturn grey. Turning a sensor on turns its motor off, because the Inverter flips the bit. -
Look inside a part
Double-click either Inverter block. The board opens the Inverter itself: one NAND with both of its inputs tied to the same wire. Click its
innode to check thatoutis always the opposite.To get back, click the ✎ next to Line Follower under Your parts.
You should see that NAND(a, a) is NOT a: in the NAND table, the rows where a and b are equal give 1 then 0.
-
Wire your first gate
Press New for an empty board, then click these in the Parts list. Each click drops a block in the middle, so drag each one aside before adding the next.
- Input twice. They're named
in1andin2. - AND once.
- Output once. It's named
out1.
To wire, press on a dot on the right edge of a block (an output), drag, and let go on a dot on the left edge of another (an input). Wire
in1to the AND'sa,in2to itsb, and the AND'souttoout1.Now click the inputs to try all four combinations.
You should see
out1light up only when both inputs are on, matching the AND table. Right-click the AND to delete it, drop in an XOR and rewire it the same way. Nowout1is on when exactly one input is on. - Input twice. They're named
-
Turn it into a brick
Press Save as… and name it My Gate. Then press New and click My Gate under Your parts.
You should see your whole circuit as a single block, with pins
in1,in2andout1. The list shows it as2→1, meaning two inputs and one output. Pin names come from the Input and Output labels, so to rename a pin, double-click its node before saving. -
Build the robot a new brain
This brain ignores the line. It drives forward and, when it hits the edge of the table, spins away. Press New and add:
- one Input. Double-click it and rename it
bump. - one NOT and one HIGH.
- three Output nodes, renamed
motorL,revLandmotorR.
Then wire
bumpto NOT, NOT tomotorL,bumptorevL, and HIGH tomotorR. In Test drive, set Brain to This circuit (live), press Reset, then Run.You should see the robot drive off the line and across the table. When it touches the edge,
bumpturns on, the left wheel runs backward while the right runs forward, and the robot spins in place until it's clear. Then it drives on. Press Save as… and call it Wall Bouncer, and it appears in the Brain menu. - one Input. Double-click it and rename it
-
Try it on your own
- NAND only. Build AND from two NANDs (one NAND, then an Inverter), and OR from three (invert each input, then NAND them). Check them against the tables.
- Both at once. Make a brain that follows the line but also backs away from the edge. Hint: start from the Line Follower, wire
bumpstraight torevL, and use an AND somotorLonly runs whilebumpis off. - Memory. Open the SR Latch. Pulse
seton and off and see thatqstays on. That's one remembered bit, made from two NORs.
My part isn't in the Brain menu. A saved brain may only have inputs named lineL, lineR or bump, and needs at least one motor output. This circuit (live) always works.
A wire won't connect. Wires go from an output dot (right side) to an input dot (left side). An input takes only one wire, so a new wire replaces the old one. Drag a wire off an input to move or remove it.
An extra Output, like out1, is sitting there. It does nothing unless something is wired to it. Right-click it, or select it and press Delete.
I overwrote an example. Save part saves over the part you opened, while Save as… makes a copy. The Examples button puts the originals back and leaves your parts alone.
The sections below are for when you want to know how the page itself works: how nested parts are flattened, why every gate waits one tick, and how the robot reads its brain.
Parts made of parts
One idea for gates, adders and brains
There are only two kinds of part. A primitive (NAND, AND, OR, NOR, XOR, NOT, HIGH) has a function from input bits to output bits. A composite has a circuit inside it: nodes, each of which is another part, and wires from output pins to input pins. Saving a circuit is all it takes to make a new part, and the new part can go inside the next one.
The Inverter that ships with the page is the smallest example: one NAND with both inputs tied together, because NAND(a, a) = NOT a. Circuits are plain data, so the examples are written with a one-line-per-wire shorthand.
{
id: 'Inverter',
name: 'Inverter',
description: 'NOT built from a single NAND with both inputs tied together.',
circuit: circuit(
[
['a', IN, 60, 100, 'in'],
['n', 'NAND', 220, 90],
['o', OUT, 400, 100, 'out'],
],
['a.out -> n.a', 'a.out -> n.b', 'n.out -> o.in'],
),
},- Three wires: the input pin feeds both NAND inputs, and the NAND feeds the output pin. The positions (60, 220, 400) only matter for drawing.
A composite part's pins aren't declared anywhere. They're read off its Input and Output nodes, so renaming a node renames the pin, and moving it up or down reorders the pins.
/** Pin order of a composite part follows the vertical order of its IN/OUT nodes. */
export function deriveInterface(c) {
const pins = (kind) =>
c.nodes
.filter((n) => n.part === kind)
.sort((a, b) => a.y - b.y || a.x - b.x)
.map((n) => (n.label ?? '').trim());
return { inputs: pins(IN), outputs: pins(OUT) };
}- Top to bottom, then left to right. This is why the Full Adder in the tests gets its pins in the order
a, b, cin: that's how the nodes are stacked.
The one thing a part may not do is contain itself, even indirectly (A inside B inside A), because flattening it would never end. Saving runs a depth-first search over what the circuit uses.
/** True if `partId` uses `target` anywhere inside it (or is it). */
dependsOn(partId, target, seen = new Set()) {
if (partId === target) return true;
if (seen.has(partId)) return false;
seen.add(partId);
const def = this.parts.get(partId);
if (!def?.circuit) return false;
return def.circuit.nodes.some((n) => this.dependsOn(n.part, target, seen));
}- Found it: this part is, or contains, the one being saved.
- Otherwise look inside every node. The
seenset stops it re-walking a part that's used many times, so a big design is checked in time proportional to the number of distinct parts.
The library also refuses unlabelled or duplicate pins, and won't delete a part that another part uses. Saved parts are written to the browser in dependency order (Inverter before Line Follower), so they can be loaded back one at a time.
Flatten before you simulate
Nesting costs nothing at run time
Simulating parts inside parts directly would mean passing signals across every boundary on every tick. Instead, compile() expands the whole design once into a netlist: a flat list of primitive gates, where each signal is just a number (a net). The simulator never sees a composite part.
Every output pin gets its own net before any wiring happens, so a wire can point at a gate that hasn't been visited yet. That is what lets circuits loop back on themselves.
// Allocate a net for every output pin up front, so wires can form loops.
const outNet = new Map();
for (const n of c.nodes) {
const def = lib.get(n.part);
if (!def) throw new Error(`Unknown part "${n.part}".`);
for (const p of def.outputs) outNet.set(`${n.id}:${p}`, b.newNet());
}
const driver = new Map();
for (const w of c.wires) {
const net = outNet.get(`${w.from.node}:${w.from.pin}`);
if (net !== undefined) driver.set(`${w.to.node}:${w.to.pin}`, net);
}
const src = (node, pin) => driver.get(`${node}:${pin}`) ?? LOW;- One fresh net per output pin, for every node in this circuit.
- A wire just records which net feeds an input pin. An input has one driver; the editor replaces the old wire if you connect a second.
- An input with no wire reads net 0, which is always low. Leaving a pin unconnected is never an error, only a 0.
Then each node is handled by kind. Gates are copied straight into the netlist. A composite part is expanded recursively, with its input pins bound to the nets outside it.
if (n.part === IN) {
if (bind) b.alias(outs[0], bind.get(label) ?? LOW);
else if (!topInputs.has(label)) topInputs.set(label, outs[0]);
} else if (n.part === OUT) {
if (!results.has(label)) results.set(label, ins[0]);
} else if (def.eval) {
b.gates.push({ partId: def.id, eval: def.eval, ins, outs });
} else if (def.circuit) {
const inner = new Map(def.inputs.map((p, i) => [p, ins[i]]));
const res = expand(def.circuit, inner, depth + 1);
def.outputs.forEach((p, i) => b.alias(outs[i], res.get(p) ?? LOW));
}- Inside a part, an Input node doesn't make a new signal. Its net is declared to be the same signal as whatever drives that pin outside.
- A primitive: add it as a gate.
- A composite: expand its circuit with the pin bindings, then declare each of its output nets equal to whatever the inner Output node reads.
“Declare these two nets equal” is exactly what union-find is for. Each net points at a parent; following parents leads to the net that represents the whole group. After expansion, every group is renumbered to one compact net, so the Line Follower, three levels deep in the editor, becomes just 2 NAND gates on 5 nets.
find(n) {
while (this.parent[n] !== n) {
this.parent[n] = this.parent[this.parent[n]];
n = this.parent[n];
}
return n;
}
/** Make a and b the same signal. LOW always stays the root. */
alias(a, b) {
const ra = this.find(a),
rb = this.find(b);
if (ra === rb) return;
if (ra === LOW) this.parent[rb] = ra;
else this.parent[ra] = rb;
}- Path halving: each lookup also shortens the chain it walks, so lookups stay close to constant time however deep the nesting.
- Net 0 always stays the root of its group, so an inner pin tied to an unconnected outer input stays low.
Every gate waits one tick
Why memory and oscillators just work
The simulator keeps two arrays of bits: the signals now and the signals next. On each tick every gate reads now and writes next, then the arrays swap. Every gate therefore has exactly one tick of delay, like a real gate's propagation delay.
That one rule is what makes feedback meaningful. Without a delay, a NOT gate wired to its own input has no consistent value. With one, it flips every tick: a clock. Two cross-coupled NOR gates hold whichever state they were last pushed into: the SR Latch in the parts list, one bit of memory.
step() {
const { values, next } = this;
next.set(values);
for (const g of this.netlist.gates) {
const outs = g.eval(g.ins.map((n) => values[n] === 1));
for (let i = 0; i < g.outs.length; i++) next[g.outs[i]] = outs[i] ? 1 : 0;
}
next[LOW] = 0;
let changed = false;
for (let i = 0; i < values.length; i++)
if (values[i] !== next[i]) {
changed = true;
break;
}
this.next = values;
this.values = next;
this.ticks++;
return changed;
}- Start from the current signals, so nets nobody drives (like the Inputs you click) keep their value.
- Every gate reads only the old array, so the order gates are listed in doesn't matter.
- Swap the two arrays instead of copying, so a tick allocates nothing.
settle() steps until a tick changes nothing, and reports when that never happens. The tests use it to check that adders settle and that a self-wired NOT doesn't. A real quirk shows up here too: the SR Latch, powered up with both inputs off, flickers forever until you pulse set or reset, which is how an idealised latch with identical gates behaves. The page's Signal speed menu sets how many ticks run per frame (1, 4 or 64), so you can slow it down and watch signals travel.
A circuit as a brain
Sense, think, act, sixty times a second
The robot is a two-wheeled, differential-drive car on a table with an oval line. Any part whose pins are named after its sensors (lineL, lineR, bump) and motors (motorL, motorR, optionally revL, revR) can be its brain. Each physics step reads the sensors, runs the brain's circuit for 32 ticks so the signals settle, and turns the motor pins into wheel speeds.
step(dt) {
const c = this.config;
this.sensors = this.readSensors();
if (this.brain) {
for (const pin of SENSOR_PINS) this.brain.setInput(pin, this.sensors[pin]);
this.brain.run(c.brainTicksPerStep);
const o = (p) => (this.brain.output(p) ? 1 : 0);
this.motors = { left: o('motorL') - o('revL'), right: o('motorR') - o('revR') };
}
const vl = this.motors.left * c.maxSpeed,
vr = this.motors.right * c.maxSpeed;
const v = (vl + vr) / 2;
this.heading += ((vl - vr) / c.wheelBase) * dt;
let nx = this.x + Math.cos(this.heading) * v * dt;
let ny = this.y + Math.sin(this.heading) * v * dt;
const r = c.radius;
const cx = Math.max(r, Math.min(c.width - r, nx));
const cy = Math.max(r, Math.min(c.height - r, ny));
this.bumped = cx !== nx || cy !== ny;- Sensors in, 32 ticks, motors out. Forward and reverse on the same wheel cancel, so a wheel is +1, 0 or −1.
- Differential drive: the difference between the wheel speeds, divided by the distance between the wheels, is the turning rate. Stop one wheel and the robot pivots toward that side.
- The table edge stops the robot and raises
bumpon the next step.
That turning rule is all the example brain needs. The Line Follower connects each sensor to the motor on the same side through an Inverter: while the left sensor sees the line, the left wheel stops and the car turns left, back onto it. Both sensors start off the line (it runs between them), so both wheels drive. In the simulator it laps the track in 17.7 seconds without its centre ever straying more than 3.4 px from the line, and a test checks a full lap on every run.
“This circuit (live)” in the Brain menu recompiles the board after every edit, so you can rewire the brain while the robot is driving.
Limits
Honest caveats
- Signals are single bits, and wheels are only full forward, stopped or full reverse. There are no buses, no analog values and no motor speed control yet.
- There's no clock part and no timer, so a brain can't yet do “back up for one second”. You can build a clock from a self-wired NOT, but its speed depends on the tick rate, not on real time.
- Every gate has the same one-tick delay. Real gates differ, and races that depend on those differences don't appear here.
- The robot is kinematic: no mass, friction or wheel slip, and the table edge simply stops it. It is meant to make the logic visible, not to model a real car.
- The editor has no undo, multi-select or copy and paste. Wiring on a phone works, but a mouse is easier.
Check it
Plain Node, no install
node --test projects/partforge/tests/*.mjs # 17 checks on the same files the page runs
The tests cover every gate's truth table, the Inverter, the half adder, a full adder built from two half adders (all eight input combinations), the SR latch remembering a bit, a self-wired NOT that never settles, the library's rules, saving and loading, and the robot: driving straight, turning, feeling a bump, lapping the track with the Line Follower, and roaming the table with a wall-bouncing brain.
Read further
Where everything lives
- web/engine/parts.js the built-in gates, pins from Input/Output nodes, the part library and its rules
- web/engine/compile.js flattening nested parts into a netlist with union-find
- web/engine/simulator.js the two-array, one-tick-per-gate simulator
- web/engine/examples.js the example parts and the circuit shorthand
- web/vehicle/world.js sensors, differential drive and the track
- web/ui/editor.js the board: placing, wiring, panning, zooming to fit, drawing live signals
- web/ui/vehicleView.js drawing the robot and its track
- web/app.js the page: parts list, saving, import and export, test drive
- tests/engine.test.mjs, tests/vehicle.test.mjs