How I built a switch-level circuit simulator that settles feedback
Most "how a CPU works" resources cheat in one of two directions. They either hand-wave the transistors ("a gate is a black box that does AND") or they drop you straight into an HDL and a waveform viewer. I wanted the middle: place two transistors, wire a NAND, watch the actual nets light up, and climb from there to a working 8-bit CPU without ever leaving the level of "a wire is on or off."
That meant writing a switch-level simulator from scratch. Here is how it works, and the two problems that were more interesting than I expected: making feedback loops hold state, and drawing the wires so an AI-generated circuit doesn't look like spaghetti.
A transistor is a switch, and that's the whole model
The simulator has no notion of a "gate." The only active components are NMOS and PMOS transistors, plus VCC and GND rails. An NMOS conducts between its source and drain when its gate reads high; a PMOS conducts when its gate reads low. Every gate you know is just a pull-up network of PMOS to VCC and a complementary pull-down network of NMOS to GND. A NAND is two PMOS in parallel and two NMOS in series. You build it by placing four transistors and wiring them; the simulator never "knows" it's a NAND.
A net (a set of wires and pins electrically joined) settles to one of four states: 1 (a driver pulls it high), 0 (a driver pulls it low), Z (floating, nothing drives it), or X (short, two drivers disagree). Those last two are not error states you hide, they are things students need to see: a floating input and a fight between drivers are real bugs, and the simulator surfaces them in the same colors everywhere.
The settle loop
Connectivity is computed with union-find: walk every wire and pin, union the nets that touch. But there's a chicken-and-egg problem. Whether two points are connected depends on which transistors are conducting, and whether a transistor conducts depends on the state of the net at its gate, which depends on connectivity. Feedback.
So the core is a fixed-point iteration:
- Start from an assumption about which transistors are closed (open, or a warm
start, more on that below).
- Build the netlist for that assumption and apply the drivers (VCC pulls its
net high, GND low, inputs their value).
- Recompute every transistor: is its gate net high or low now? That gives a new
set of closed transistors.
- If the set changed, go back to step 2. If it's stable, you've converged.
The number of passes needed is roughly the logic depth of the circuit, a signal rippling through N stages needs about N passes. I originally capped it at a flat 100 iterations, which quietly mislabeled deep combinational circuits (a wide ALU, a multi-stage datapath) as "oscillating" before they could settle. The fix was to scale the cap with the flattened primitive count: clamp(primitiveCount, 100, 2000). Depth can't exceed the primitive count, so this always gives enough passes while still terminating a genuinely oscillating ring quickly.
Warm starting is what gives latches memory
Here's the part I didn't anticipate. A combinational circuit settles to the same fixed point no matter what you assume in step 1, so you can start from "all transistors open" and it doesn't matter. But an SR latch or a flip-flop has two stable fixed points, and which one it lands in depends on its history.
The trick is the warm start. Each simulation returns the set of transistors that were closed when it converged, and the next simulation begins from that set instead of from all-open. Now the latch's cross-coupled feedback keeps its conducting path alive from one instant to the next: it *remembers*. Run the same circuit with no warm start and it forgets, which is exactly what you want for a stateless truth table, and wrong for anything sequential.
This one detail is the whole difference between "a truth-table calculator" and "a thing that can simulate a CPU running a program over clock cycles." A register holds its value because last tick's closed transistors are this tick's starting assumption.
Drawing the wires was harder than simulating them
The layout problem sounds cosmetic and turned out to be the deepest rabbit hole. When the built-in AI tutor generates a circuit, it emits a *logical netlist*, a list of parts and which pins connect, with no coordinates. Something has to place those parts and route the wires so a human can read the result.
Placement uses a layered approach (and ELK, the Eclipse Layout Kernel, for block-level datapaths). But the wires were the problem: a naive L-shaped route between two pins happily draws straight through a transistor's body. It's electrically fine, the data model is just endpoints, but it *looks* wrong, and worse, it looks like a connection that isn't there.
So wires route with an A\* search over the grid, with component bounding boxes as obstacles. The cost function charges 1 per step plus a bend penalty (I use 5), so it prefers few corners. The subtle part is a hazard specific to circuit routing: if a rerouted wire passes *through* another net's pin or drops a corner on another wire, union-find happily merges those nets and silently changes the circuit. So the router treats other pins and wire endpoints as blocked cells, and lets a wire cross another wire's interior straight through but never *turn* there. The whole thing is bounded (a search window plus a 30k-node cap) and falls back to the naive route on failure, so it can never hang.
There's a verification gate on top of all of it: after the AI proposes a layout, the real engine flattens it and rebuilds the netlist, and the layout is only accepted if the resulting net partition *exactly* equals the intended one. A buggy layout strategy can only ever be rejected, never ship a wrong circuit.
One more visual lie I had to kill: I originally rendered wires as flowing bezier cables, which looked slick but bulged several grid cells off their true path, visually sweeping across neighboring components. A curve that deviates from the routed path is a lie about the geometry. They're now rounded-orthogonal traces that follow the routed grid exactly, with small corner arcs. What you see is the route.
The stack
The frontend is dependency-free TypeScript and SVG on purpose, the only runtime dependency is the layout kernel, and it's lazy-loaded into its own chunk so the editor never pays for it up front. The engine (model + simulator) has no DOM references at all, so a desktop wrap can reuse it later. The backend is Node and SQLite with a hand-written router, no framework.
If you want to poke at any of this, the simulator is the thing to start a NAND in and flip an input: digiwleea.wleeaf.dev. Build a transistor, wire it, watch it light up. Happy to go deeper on the solver or the router in the comments.
This is from building digiwleea, a free browser lab where you build a CPU starting from single transistors. Go move things around and try to break a circuit.