Digital logic — from NAND to a computer
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.
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:
| chip | what it does | NAND gates |
|---|---|---|
NOT | tie both inputs together — NAND(a,a) = ¬a | 1 |
AND | a NAND, then invert it | 2 |
OR | De Morgan: invert both inputs, then NAND | 3 |
NOR | OR into NOT | 4 |
XOR | high when the inputs disagree | 4 |
XNOR | one-bit equality | 5 |
ADD-2 | half adder: XOR is the sum, AND is the carry | 6 |
ADDER | full adder — A + B + carry-in | 15 |
SR-LATCH | one bit of memory, from two NANDs feeding each other | 4 |
D-LATCH | transparent while enabled, frozen when not | 4 |
D-FLOP | edge-triggered: two D-latches on opposite clock phases | 9 |
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.
- primitives (7) —
NAND,TRI,CLOCK,PULSE,BUS,LED,7-SEG. These are hardware. They have no JSON definition and nothing inside them; the descent stops here because there is no “here” underneath.NANDis the only one that does logic. - simple (11) — the other gates, the adders, the latches and flip-flops. Each is a handful of NANDs and each opens up.
- modules (34) — everything larger, plus whatever you package:
| group | modules |
|---|---|
| select | MUX-2 MUX-4 DEMUX-4 DECODE-2 ENCODE-4 |
| compare | EQ-1 EQ-4 CMP-1 CMP-4 |
| arithmetic | ADD-4 SUB-1 SUB-4 INC-4 NEG-4 ADDSUB-4 MUL-2 ALU-1 |
| sequential | T-FLOP JK-FLOP DIV-2 REG-EN REG-4 SHIFT-4 RING-4 COUNTER-4 COUNT-10 EDGE-DET |
| memory & bus | RAM-4X1 RAM-4X2 RAM-8X1 BUS-2 |
| display | SEG-DEC SEG-DRIVE |
| machine | DATAPATH |
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
- Click a chip in the library, then click the board to drop it. Hold shift to place several.
- 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.
- Click the round switches on the left rail to drive the circuit. The right rail lights up.
- 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:
- A wire has no delay of its own. The one-frame delay belongs to the chip driving it. Frames, not seconds, are the unit of time here.
- A pin can have several drivers. When they disagree, the simulation picks between them at random, frame by frame. That is bus contention rendered honestly rather than reported as an error.
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:
| project | what it is |
|---|---|
ADDER-2BIT | two full adders, carry chained — binary addition on switches |
COUNT-DISPLAY | clock → decade counter → decoder → a real seven-segment digit |
FETCH-DATAPATH | a program counter walking memory into an accumulator |
MEMORY-8 | eight words of RAM on switches, DOUT lit by the selected word |
ALU-LAB | the one-bit ALU: A, B, carry and a two-bit opcode |
SHIFT-SCOPE | a 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:
- Two-bit binary adder
- Decade counter driving a seven-segment display
- Program counter, memory and accumulator
- Eight words of RAM
- A one-bit ALU
- A four-bit shift register
+ 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.