Digital logic — from NAND to a computer

· updated

Every computer that has ever been built is, underneath, one gate repeated a few billion times. This is a sandbox for taking that claim seriously: you get one NAND gate and two rails of switches, and everything else — inversion, AND, addition, memory — is something you wire together yourself and then package into a block you can reuse.

It is a single self-contained HTML file. No build step, no server, nothing leaves the page.

Open fullscreen

Credit where it is due

The idea, the interaction model and — most importantly — the simulation algorithm come from Sebastian Lague’s Digital-Logic-Sim (MIT), built for his series Exploring How Computers Work. The original is a Unity application; this is a from-scratch browser port of its core loop, written against the C# in Assets/Scripts/Simulation/. If you like this, go and watch the series and play the real thing — it goes much further than a web page reasonably can.

The one rule

NAND(a, b) is low only when both inputs are high. That is the entire axiom. Everything below is a consequence of it:

chipwhat it doesNAND gates
NOTtie both inputs together — NAND(a,a) = ¬a1
ANDa NAND, then invert it2
ORDe Morgan: invert both inputs, then NAND3
NOROR into NOT4
XORhigh when the inputs disagree4
XNORone-bit equality5
ADD-2half adder: XOR is the sum, AND is the carry6
ADDERfull adder — A + B + carry-in15
SR-LATCHone bit of memory, from two NANDs feeding each other4
D-LATCHtransparent while enabled, frozen when not4
D-FLOPedge-triggered: two D-latches on opposite clock phases9

Those chips ship in the library, but none of them is special — each is stored in exactly the format your own chips are, and you can open any of them with the ✎ handle and take it apart. NOR is worth opening first: it contains no NAND at all, only an OR and a NOT, which is what composition buys you.

Three tiers, and the difference matters

The library bar is grouped, because the groups mean genuinely different things. One horizontally-scrolling row per tier — four rows once projects are counted — so a fifty-two module library stays four lines tall and you always know which shelf you are on.

groupmodules
selectMUX-2 MUX-4 DEMUX-4 DECODE-2 ENCODE-4
compareEQ-1 EQ-4 CMP-1 CMP-4
arithmeticADD-4 SUB-1 SUB-4 INC-4 NEG-4 ADDSUB-4 MUL-2 ALU-1
sequentialT-FLOP JK-FLOP DIV-2 REG-EN REG-4 SHIFT-4 RING-4 COUNTER-4 COUNT-10 EDGE-DET
memory & busRAM-4X1 RAM-4X2 RAM-8X1 BUS-2
displaySEG-DEC SEG-DRIVE
machineDATAPATH

Every one of those is written in the JSON format below and compiled through the same importer your own files go through — there is no privileged path. Open RAM-8X1 and you can walk down four levels of module before you run out: RAM-8X1 → RAM-4X1 → DECODE-2 → NOT → NAND, 85 NAND gates in total. It bottoms out in two primitives rather than one — NAND and TRI — because a memory that puts several cells on one output wire needs tri-state buffers to do it. The deepest thing here is CMP-4 at five levels and 114 gates.

The filter box narrows every shelf at once, and typing anywhere on the board jumps straight into it.

The header keeps a running count of what the board on screen flattens to. A full adder reads 5 parts · 15 nand — and at four transistors per NAND in CMOS, that is 60 transistors to add two bits.

The loop

  1. Click a chip in the library, then click the board to drop it. Hold shift to place several.
  2. Click any output pin to start a wire and any input pin to land it. Click empty board in between to bend it around something. Right-click drops the last bend, or cancels.
  3. Click the round switches on the left rail to drive the circuit. The right rail lights up.
  4. package… names the board and puts it in the library. From then on it is one block with the pins you gave it, usable inside the next thing you build, arbitrarily deep.

Right-click deletes whatever is under the cursor — a chip, a wire, a rail pin, or every wire attached to one pin. Drag a box to select a group.

Two knobs set the pace and they do different jobs. clock is a rate in hertz — right is faster, the track is logarithmic and runs a decade either side of the 1 Hz mid-point, so 0.1 Hz at the left stop and 10 Hz at the right. speed is a fast-forward multiplier, ×1 to ×20, and it scales everything: the clock rate and how quickly a deep combinational circuit settles in real time. Both start where a counting display is legible — ×1 and 1 Hz, one count a second.

The number the clock knob shows is the rate you actually get. That is worth saying because it costs something: for a few frames after every edge the board is still settling and a ripple carry really is holding 3 between 1 and 2, so the paint loop steps past that window rather than drawing it, and those frames take no wall-clock time. The rate maths adds them back. The readout past the knobs shows the clock’s level, the frames left to its next edge, and the true rate including the multiplier.

space runs and pauses, s steps one frame, S jumps to the clock’s next edge (ten frames if the board has no clock), r resets. Those four are reserved, so typing any other character filters the library — the box takes focus and narrows every shelf as you type, and esc clears it and hands focus back to the board. / is the way in when the thing you want starts with an s or an r. and scroll whichever shelf the pointer is over, or all of them at once; each shelf also has its own arrows.

Looking inside

Once a module works you stop looking at it. That is the point of packaging — and it is also how you lose track of what a circuit actually is.

So click any module and it opens as a workspace of its own. Not a diagram of that chip and not a listing — the same board you were just working on, now showing that module’s insides, with the real signals running through them. Click something inside it and you go deeper again: a 2-bit adder is two full adders, each a handful of XORs and ANDs, each four NANDs. The descent ends at NAND, because there is nothing underneath it.

Every level is editable. Add a gate inside XOR and you have changed XOR — the definition, so every copy of it on every board changes with it, and the NAND count on the header moves accordingly. What you cannot touch are the module’s own ports on the left and right rails: whatever placed the module is wired to those, so they are driven from above. The circuit between them is yours.

The bar under the board is the navigation. It shows the stack you are inside — load ADDER-2BIT and click twice and it reads ADDER-2BIT / ADDER / XOR — and every level is one click away, so you can drop four levels down to change one gate and be back on the board in one click. Right-click or esc comes up a level.

The tree button on that bar opens a hierarchy panel for wherever you are standing: expand it branch by branch, all the way to the primitives, without the canvas moving off what it is showing. Each row carries the live state of that instance’s outputs, and jumps the canvas straight to it.

Why the engine matters

The tempting way to simulate a circuit is to iterate to a fixed point. That produces correct truth tables and a completely wrong intuition, because it quietly assumes every circuit has a stable answer.

The original does something better, and this port copies it closely: a chip is processed as soon as all of its inputs have arrived, subchips are visited in reverse array order, and every hundredth frame two chips that are still waiting may swap places. Nothing is being approximated there — that reordering is the model of propagation delay.

You can watch it matter. Build an SR-LATCH, raise both S and R, then release them together. The latch has to land somewhere, and which way it lands is settled by whichever of its two NANDs the traversal happens to reach first — a real race, not a defined behaviour. Be precise about what that means, though: the visitation order is sticky once established, so a given board tends to fall the same way every time you try it. The outcome is order-dependent, not random. Wire a NOT back into its own input and it will oscillate instead of finding an answer at all.

Two consequences worth knowing:

Floating is a third state

Pins are tri-state: high, low, or disconnected — drawn as a dashed grey wire. A pin nothing drives is floating, not zero, and the distinction is the entire reason the TRI buffer exists. It passes its input while EN is high and disconnects completely while EN is low, which is how several chips share one wire without fighting: exactly one of them is enabled at a time. That arrangement is a bus, and it is how a CPU talks to memory.

Describing circuits in JSON

Wiring by hand is the right way to learn and the wrong way to build anything large. So the whole thing has a text format, and it is deliberately written to be easy for a language model to produce correctly:

{
  "format": "robertz-logic/v1",
  "modules": [
    {
      "name": "HALF-ADDER",
      "in":  ["A", "B"],
      "out": ["SUM", "CARRY"],
      "parts": { "x": "XOR", "a": "AND" },
      "wires": [
        ["A", "x.A"], ["B", "x.B"],
        ["A", "a.A"], ["B", "a.B"],
        ["x.OUT", "SUM"], ["a.OUT", "CARRY"]
      ]
    }
  ],
  "top": "HALF-ADDER"
}

Everything is addressed by name, never by index. A bare endpoint like "A" is a port of the module being defined — an input when it is the source of a wire, an output when it is the destination. "x.OUT" is a pin on the part you called x. Wires always run source → sink.

Positions are optional. Leave them out and the importer ranks the parts by how deep they sit in the signal flow and lays the schematic out itself, so a module that arrives as pure logic still reads as a circuit.

The json docs button in the library bar prints the full specification as markdown, with two tables generated from whatever is loaded at that moment: the primitives, and every module already in your library. Copy it, paste it into a chat model, say what you want built, and feed the JSON back through import. Because the model is handed the real library, it will reuse your modules rather than reinventing them.

Bad JSON is rejected with the specific problem rather than a shrug:

module "BAD2": part "x" is a XOR (in: A, B / out: OUT) and has no
input pin "Z" in wire 1 ("x.Z").

module "BAD3": part "x" is a XOR (in: A, B / out: OUT) and has no
output pin "A" in wire 1 ("x.A"). It exists, but on the other side —
check the wire direction.

export writes your whole project back out in the same format, positions included, so the round trip is lossless and you can keep a circuit in version control as readable text.

Your library lives in your browser

Everything you build is written to IndexedDB — a real browser database, not a cookie. Cookies cap out around 4 KB and ride along on every HTTP request; a chip library is tens of kilobytes and belongs nowhere near the network. If IndexedDB is unavailable the sim falls back to localStorage, and the badge in the header tells you which one you got.

Close the tab and come back: the library and the board you were working on are both still there. export writes the whole project to a JSON file and import merges one back in by name, which is also how you move work between browsers. Nothing is ever uploaded.

What is deliberately missing

The original has 4-bit and 8-bit pins with SPLIT/MERGE chips, RAM, ROM, an RGB display and a buzzer. None of that is ported. This sandbox is one bit wide everywhere, which is the version of the model that makes the NAND story legible — you can still build an 8-bit adder, you just wire eight of them. Displays also render only when placed directly on a board, not when buried inside a packaged chip.

What is here beyond the gates: CLOCK (a square wave, its period set by the clock knob), PULSE (a one-shot on each rising edge), TRI, BUS (a relay for tidying long runs), LED (a probe lamp you can drop mid-circuit) and 7-SEG, whose seven bars and dot are driven one input each.

How the shipped modules were written

They were drafted by four language models, each handed exactly the markdown the json docs button produces and asked for one domain — selection, arithmetic, sequential, memory and display. Nothing was taken on trust: every returned module was compiled and then driven through the engine against a reference implementation computed independently of it. CMP-4 was checked across all 256 pairs, SEG-DEC across the whole digit font, MUL-2 across every product, RAM-8X1 by writing and reading all eight addresses. Eighteen were proposed and eighteen passed.

One of those passes was worthless, and it is worth saying so. RING-4 — four flip-flops in a ring — was checked for the property “the pattern rotates one place per clock”, and an all-zero ring satisfies that trivially. It had no reset and no load, so nothing could ever get a bit into it: the module could not do anything at all, and the test happily agreed that it rotated. It now has CLR and SET, and the check clears it, seeds one bit and requires the bit to still be there as it walks round.

The lesson generalises past this sandbox: a passing test proves nothing until you know it would have failed. scripts/verify-sim-logic.mjs in the repo now drives all forty-five composed modules against references computed outside the simulator, and refuses to report a pass count without its denominator. The seven primitives are not in that count because they are not composed of anything — they are checked by the sim’s own selfTest() instead.

That is also the workflow the docs button is for. Ask, import, and let the truth table decide.

Projects — whole boards on a shelf

The fourth shelf in the library holds projects: complete boards, switches and wiring and displays included, where a module is just a part. Click one and it replaces the board and starts running; your module library is never touched. Six ship, chosen to be the things people actually build first:

projectwhat it is
ADDER-2BITtwo full adders, carry chained — binary addition on switches
COUNT-DISPLAYclock → decade counter → decoder → a real seven-segment digit
FETCH-DATAPATHa program counter walking memory into an accumulator
MEMORY-8eight words of RAM on switches, DOUT lit by the selected word
ALU-LABthe one-bit ALU: A, B, carry and a two-bit opcode
SHIFT-SCOPEa shift register as a four-lamp oscilloscope

Everything in the library has an address. Load a project or open a module and the URL follows: /sim/logic/?p=COUNT-DISPLAY for a project, /sim/logic/?m=XOR for any module or simple gate. Copy it out of the address bar and it opens exactly what you were looking at; the browser’s back and forward buttons move between them. Embedded in a page the sim replaces its history entry instead of pushing, so the article’s own back button still works.

The six projects also have pages of their own:

+ project saves whatever is on the board under your own name — positions included — to the same shelf. Projects persist in the browser database and ride along with export/import, stored as the same board object the JSON format already defines.

FETCH-DATAPATH is still a fetch datapath, not a CPU: there is no instruction set and nothing decodes what memory returns. It is the loop a processor is built around — address, fetch, latch — and the honest next step is a decoder that turns the fetched bits into control lines.

One clock domain

Every CLOCK instance — on the board, or buried three modules deep — ticks in phase from the same toolbar knob. XOR two of them and you get a constant 0. That is a deliberate design, and it is how real synchronous hardware is built: one clock domain, distributed to everything sequential. It also matches the original simulator, where the tick rate is a project-wide setting.

Both clock styles work, verified: a module can take CLK as an input port and be driven from outside (the right pattern for anything reusable — COUNTER-4, REG-4 and friends all do this), or it can embed its own CLOCK and tick with no wiring at all, at any nesting depth. Gating is the DATAPATH pattern: AND the clock with an enable and everything downstream freezes mid-count and resumes exactly where it stopped.

Where to go next

Press json docs, then load the example: two modules arrive as text and become a working 2-bit adder, and you can immediately drill from it down to a single NAND. Then ask a model for a 4-bit version, or a multiplexer, or a register file, and import what it gives you.

Or do it by hand: chain four ADDERs carry-out to carry-in, package it, and drive a 7-SEG from the result. Then a register — four D-FLOPs sharing one clock. At that point you have the two halves of a CPU, something that computes and something that remembers, and the rest is wiring.

logic