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

  1. Drawparts and wires on the board; Input and Output nodes are the pins
  2. Savethe circuit becomes a part, checked so it can't contain itself
  3. Flattennested parts are expanded into one flat list of gates
  4. Tickevery gate reads the last tick's signals and writes the next
  5. Drivesensors go in, 32 ticks run, motor pins turn the wheels

The ideas

  1. ALogic gates in two minutes
  2. BTutorial: your first brain
  3. 01Parts made of parts
  4. 02Flatten before you simulate
  5. 03Every gate waits one tick
  6. 04A circuit as a brain
  7. →Limits
  8. →Check it
  9. →Read further
A

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.

inout
01
10

Stop the left wheel when the left sensor sees the line.

AND

On only when both inputs are on.

about
000
010
100
111

Drive only if the path is clear and the battery is fine.

OR

On when either input (or both) is on.

about
000
011
101
111

Back up if the bump sensor or a line sensor fires.

XOR

On when the inputs are different.

about
000
011
101
110

Exactly one line sensor sees the line: the car is drifting.

NAND

AND, then flipped: off only when both are on.

about
001
011
101
110

The universal gate. Every other gate can be built from NANDs alone.

NOR

OR, then flipped: on only when both are off.

about
001
010
100
110

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:

lineLlineRmotorLmotorRWhat the robot does
offofffwdfwdDrives straight. The line is between the sensors, where it should be.
ONoffstopfwdIt 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.
offONfwdstopThe mirror image: it swings right.
ONONstopstopBoth 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.

B

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.

  1. 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 / lineR blink 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.

  2. Flip the inputs yourself

    The board shows the Line Follower's wiring. Its inputs are the rounded lineL and lineR nodes on the left. Click lineL once 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 lineL turn green and the wire into motorL turn grey. Turning a sensor on turns its motor off, because the Inverter flips the bit.

  3. 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 in node to check that out is 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.

  4. 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 in1 and in2.
    • 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 in1 to the AND's a, in2 to its b, and the AND's out to out1.

    Now click the inputs to try all four combinations.

    You should see out1 light 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. Now out1 is on when exactly one input is on.

  5. 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, in2 and out1. The list shows it as 2→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.

  6. 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, revL and motorR.

    Then wire bump to NOT, NOT to motorL, bump to revL, and HIGH to motorR. 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, bump turns 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.

  7. 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 bump straight to revL, and use an AND so motorL only runs while bump is off.
    • Memory. Open the SR Latch. Pulse set on and off and see that q stays on. That's one remembered bit, made from two NORs.
If something doesn't work

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.

01

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.

web/engine/examples.jslines 20–32
  {
    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'],
    ),
  },
  1. 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.

web/engine/parts.jslines 62–70
/** 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) };
}
  1. 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.

web/engine/parts.jslines 82–90
  /** 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));
  }
  1. Found it: this part is, or contains, the one being saved.
  2. Otherwise look inside every node. The seen set 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.

02

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.

web/engine/compile.jslines 36–48
    // 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;
  1. One fresh net per output pin, for every node in this circuit.
  2. 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.
  3. 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.

web/engine/compile.jslines 59–70
      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));
      }
  1. 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.
  2. A primitive: add it as a gate.
  3. 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.

web/engine/compile.jslines 14–28
  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;
  }
  1. Path halving: each lookup also shortens the chain it walks, so lookups stay close to constant time however deep the nesting.
  2. Net 0 always stays the root of its group, so an inner pin tied to an unconnected outer input stays low.
03

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.

web/engine/simulator.jslines 32–50
  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;
  }
  1. Start from the current signals, so nets nobody drives (like the Inputs you click) keep their value.
  2. Every gate reads only the old array, so the order gates are listed in doesn't matter.
  3. 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.

04

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.

web/vehicle/world.jslines 99–117
  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;
  1. Sensors in, 32 ticks, motors out. Forward and reverse on the same wheel cancel, so a wheel is +1, 0 or −1.
  2. 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.
  3. The table edge stops the robot and raises bump on 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