DEV Community

Cover image for I know what to build next. My own architecture disagrees about the order
Den
Den

Posted on

I know what to build next. My own architecture disagrees about the order

In the last post
I compared Boolflow, my browser-based
digital logic simulator, against nine other tools and came away with three
honest gaps: no sub-circuits, no waveforms, no test cases. I ended that article
with a tidy numbered list and the confidence of a man who had not yet opened the
files.

Then I opened the files.

This post is what I found. It is a roadmap, but it is the kind of roadmap you
get after reading your own code rather than before — which is to say the order
changed, the list got longer, and the reason is a single design decision I made
about eighteen months ago without noticing I was making it.

The fact underneath everything

Here is the whole simulation entry point. Fifty-six lines, and I am showing you
the load-bearing half:

export function simulate(nodes, edges, seedVals = {}) {
  const vals = { ...seedVals };

  // ... seed INPUT / CLOCK / CONST nodes into vals ...

  simulateComb(combNodes, getInput, vals);     // pass 1: upstream combinational
  simulateStateful(statefulNodes, getInput, vals);
  simulateComb(combNodes, getInput, vals);     // pass 2: downstream

  return vals;
}
Enter fullscreen mode Exit fullscreen mode

Three phases. Combinational, then stateful, then combinational again. It takes a
flat list of nodes and edges and returns a flat object of wire values.

"Phase" rather than "pass", because each combinational phase is itself a loop —
it relaxes the whole network repeatedly until, in principle, everything settles:

export function simulateComb(combNodes, getInput, vals) {
  const iters = Math.max(combNodes.length * 2, 10);

  for (let pass = 0; pass < iters; pass++) {
    combNodes.forEach(n => { /* evaluate one gate into vals */ });
  }
}
Enter fullscreen mode Exit fullscreen mode

Look at what is missing. There is no time parameter. There is no delay model, no
event queue, no notion of "before" and "after". simulate() does not advance a
simulation — it computes what all the wires settle to, right now, given the
current inputs. Call it twice with the same arguments and, for a purely
combinational circuit, you get the same answer twice.

Look also at what that loop does not do: it never checks whether anything
changed. It runs its full 2N iterations every single time, and it has no way to
report that the network failed to settle. I will come back to both of those at
the end, because together they turn out to be the cheapest thing on this entire
page.

Boolflow does not simulate time. It computes steady states.

That is not a bug. For a teaching tool it is arguably the right call: a beginner
toggling an INPUT wants to see the output change, not to reason about
nanosecond-scale propagation. It is also why the whole engine is under 600 lines
and runs instantly on every keystroke.

But it means time in Boolflow is emergent. It exists only because something
else happens to call simulate() again. And what calls simulate() again is
React:

const interval = setInterval(() => {
  setLayers(ls => ls.map(l => ({
    ...l,
    nodes: l.nodes.map(n => {
      if (n.data?.type !== 'CLOCK' || n.data.running === false) return n;
      return { ...n, data: { ...n.data, value: n.data.value ? 0 : 1 } };
    }),
  })));
}, Math.round(1000 / freq));
Enter fullscreen mode Exit fullscreen mode

A setInterval flips the CLOCK node's value, React re-renders, a useMemo
recomputes the simulation. One clock tick equals one React render.

Every item on my roadmap collides with that sentence.

Sub-circuits: the problem is not drawing, it is identity

The visible half of sub-circuits is easy and boring: save a circuit, show it in
the palette, draw it as a box with pins. A weekend, maybe two.

The invisible half is this file, and specifically these four lines:

// Persistent state for edge-triggered / stateful elements.
// Lives at module level so state survives across simulate() calls.
export const nodeStates = new Map();
export function clearNodeState(id) { nodeStates.delete(id); }
Enter fullscreen mode Exit fullscreen mode

Every flip-flop, latch, counter and register in Boolflow keeps its state in one
module-level Map, keyed by node id. A D flip-flop reads and writes it like
this:

const st = nodeStates.get(n.id) ?? { q: 0, prevClk: 0 };
let q = st.q;
if (clk === 1 && st.prevClk === 0) q = d ?? 0;
nodeStates.set(n.id, { q, prevClk: clk ?? 0 });
Enter fullscreen mode Exit fullscreen mode

Note prevClk. That is how edge detection works: compare this call's clock value
to the previous call's. The flip-flop's entire sense of time is "the last time
somebody ran the simulation." It is a global mutable Map standing in for a
clock, and for a flat circuit it works perfectly well.

Now instantiate a sub-circuit containing that flip-flop twice.

Both instances contain a node whose id is, say, n7. Both read and write
nodeStates.get('n7'). Your two counters are now the same counter, wired to
different pins, fighting over one entry in a Map. Put a 4-bit register inside a
block and drop four of them on the canvas, and all four hold identical data
forever.

So sub-circuits are not a drawing feature. They are a request to make node
identity hierarchical: a node is no longer n7, it is
inst3/inst1/n7 — a path from the top-level sheet down through every enclosing
instance.

There are two ways out.

Recursive evaluation. Teach simulate() to descend into a sub-circuit,
evaluate it with its own scoped state, and return its outputs. Conceptually
clean. It also means threading an instance path through every function that
touches nodeStates, and it makes the value map — currently a flat
{"n7:out-0": 1} — into something nested or path-keyed.

Flattening. When a sub-circuit is placed, inline a copy of its nodes and
edges into the parent, rewriting every id to instanceId/originalId. The
simulator never learns that hierarchy exists; it keeps receiving one flat list,
just a longer one. All the work moves into the editor layer and one id-rewriting
function.

I am going with flattening, for three reasons. It leaves the simulation engine
almost untouched, which matters when the engine is the part I trust most. It
gives correct per-instance state for free, because rewritten ids are unique by
construction. And it is what Digital does
— its docs note that embedded circuits are included as often as the circuit is
used — which is reassuring, because Digital is the most rigorously built tool in
this category.

The cost is honest and worth stating: flattening a block used fifty times means
fifty copies in memory, and a deep hierarchy multiplies. A tool built for CPU
scale would not accept that. Boolflow is built for someone learning what a
register is, and at that scale I will take the trade.

Waveforms: I have to make time real first

A timing diagram plots signal values against time. I have signal values. I do
not have time.

What I have is a setInterval that mutates a React node and hopes. To draw a
waveform I need three things that do not exist yet:

  1. An explicit tick counter — a monotonically increasing integer that says which simulation step we are on, owned by the simulator rather than inferred from React's render cycle.
  2. A history buffer — a ring buffer recording the value map at each tick, for the last N ticks. This part is genuinely easy; simulate() already returns exactly the object that needs recording.
  3. A step control — the ability to advance one tick deliberately, so a learner can walk a shift register through its states instead of watching it blur past at 4 Hz.

Item 3 is the one I actually care about. Watching a counter run is mildly
interesting; stepping it one clock edge at a time while reading the flip-flop
outputs is where the concept lands. Logicly has had step-by-step propagation
debugging for years and it is the single feature of theirs I have been quietly
jealous of.

While reading this code I also found a bug I had never noticed:

const freq = allClocks[0]?.data.freq ?? 1;
Enter fullscreen mode Exit fullscreen mode

Every CLOCK element on the canvas ticks on one shared interval, running at
whichever clock happens to be first in the array. Give two clocks different
frequencies and Boolflow will cheerfully ignore you. Nobody has reported it,
which I suspect means nobody has tried — but the fix belongs in exactly this
refactor, because a real tick counter makes per-clock division trivial instead
of awkward.

There is one thing a waveform in Boolflow will not show, and I would rather say
it here than have someone find out the hard way: glitches. With no propagation
delay model, a hazard that a real circuit would exhibit as a brief spurious pulse
simply does not exist in my engine. The diagram will be idealised. For teaching
what a shift register does, idealised is fine and possibly better. For teaching
why you need a synchroniser, it is a lie, and the metastability article I wrote
will have to keep saying so in words.

Test cases: the cheap one, for a good reason

Of the three features this is the least work, and it is worth understanding why,
because the reason is not that testing is simple.

Boolflow already has a truth table generator. To build a truth table for a
combinational circuit you enumerate every input combination, force the INPUT
nodes to each combination in turn, run the simulation, and read the OUTPUT
nodes. That is: set inputs, evaluate, collect outputs, in a loop, headless,
without touching the canvas.

That is also exactly what a test runner does. The only thing a test case adds is
a column of expected values and a comparison. The machinery is already written
and already shipping.

For combinational circuits I could have this working in a couple of evenings.
For sequential circuits — the ones where testing actually earns its keep — a
test case is a sequence of steps with expected outputs after each one, which
means it needs the tick counter from the waveform work. So it is cheap, but only
after something else is done.

Digital's model is the one to copy: declare inputs and expected outputs in a
table, run, get pass or fail. It is also the missing half of "exercises with
checkable answers", which I promised readers two articles ago and have not
delivered.

So the roadmap has five items, not three

Reading the code turned a list of three features into a dependency graph:

make time explicit ──────┬──→ waveforms + step mode
                         └──→ sequential test cases

hierarchical node identity ───→ sub-circuits
Enter fullscreen mode Exit fullscreen mode

Which gives an actual order:

  1. Explicit tick counter and history buffer. Invisible to users. Unblocks two of the three features and fixes the shared-clock-frequency bug on the way past.
  2. Step mode and the waveform panel. The first visible payoff.
  3. Hierarchical node ids by flattening. Also invisible, also a prerequisite.
  4. Sub-circuits. The largest gap against every other tool in the comparison.
  5. Test cases, combinational first, sequential once step mode lands.

Two of the five ship with nothing to show for them. That is the part I did not
expect when I wrote the confident numbered list last month, and it is the honest
shape of the work.

Four more things the comparison suggested, and one trap

The five above are commitments. These are candidates — things I noticed while
looking at other tools, none of which I have promised anyone. I am listing them
because the cheapest one is genuinely embarrassing.

Tell the user the circuit did not settle. Remember that relaxation loop with
no convergence check. Add a comparison of vals before and after each iteration
and you get two things from about ten lines: an early break when the network is
stable, which stops a hundred-node circuit from grinding through two hundred
pointless iterations on every render — and the ability to say "this circuit is
oscillating" when the loop runs out without settling.

Right now a ring of three inverters just exhausts its iteration budget and
returns whatever state it happened to stop on. Boolflow reports a confident,
wrong answer with no warning at all. Oscillation detection is one of the marquee
reasons people move from Logisim to Digital; in my engine it costs ten lines and
I simply had not thought to look.

A link to a circuit. Falstad, CircuitVerse and DigiSim all let you send
someone a URL. Boolflow lets you download a JSON file and email it, like it is

  1. The awkward part is that the backend for this already exists — the endpoints to store a scheme and fetch it by id have been written and working for months, and nothing in the UI ever calls them. This is not a feature, it is a missing button.

An <iframe> embed. This follows almost free from the link, and of
everything on this page it is the item with the best odds of mattering. In the v4
post I wrote that a browser tool has almost nothing for a search engine to index
and that this might decide whether anyone ever finds the project. An embed is the
answer to exactly that: every live circuit on somebody else's course page or blog
post is both a working demo and a way in. It is the only item here that
compounds.

A truth table that turns back into a circuit. Boolflow already goes from
circuit to truth table. Digital goes the other way too — you type the table you
want and it synthesises and minimises the logic. That is a self-contained module
that emits nodes and edges into the existing structures and touches the engine
not at all, and it would give the Karnaugh map article something to link to
instead of leaving the reader with nowhere to click. In the desktop tier Digital
has this. In the browser tier, essentially nobody does.

And the trap: buses. Multi-bit wires sit innocently in a feature list next to
those four, and CircuitVerse and LogicCircuit both have them. But today a wire
value is one bit — vals is a flat map of "n7:out-0" → 1. Making a port carry
four bits rewrites every gate in the combinational evaluator, all three
exporters, the wire renderer and the port definitions. It is the single most
invasive item anyone could reasonably ask me for, and it looks like the smallest.
I am not touching it before sub-circuits exist.

The pattern I did not expect: three of those four are cheap precisely because
they do not involve the simulator. The expensive work is all in the engine, and
the engine is the part I was proudest of.

What I am still not building

Unchanged from the comparison post: no accounts, no groups, no assignments, no
grading, no teacher dashboards. CircuitVerse has done all of that properly for
years, and there is no version of this where I do it better alone. If you need a
classroom platform, use CircuitVerse — I mean that as a recommendation, not a
concession.

Boolflow's job is to be the thing you open in a tab at 2am with no account and no
install, build something, and understand it.

Questions I would like answered

  • Flattening versus recursive evaluation — has anyone here built hierarchy into a simulator and regretted flattening? The memory cost is the obvious objection; I want to hear the non-obvious one.
  • Idealised waveforms — if you teach digital logic, is a timing diagram with no propagation delay useful, or is it actively misleading? I can fake a uniform unit delay per gate, but faking it convincingly may be worse than admitting the model has no delay at all.
  • Step mode granularity — one clock edge per step, or one gate evaluation per step? Digital does single-gate stepping specifically to debug oscillation. Clock-edge stepping is simpler and probably what a learner wants. I keep changing my mind.
  • Is two invisible refactors out of five a reasonable thing to publish? I went back and forth on whether a roadmap post should only contain things users can see.
  • Embeds versus sub-circuits — the disciplined answer is to finish the five before touching the candidate list. The other answer is that an embed takes a fraction of the effort and is the only item that brings new people in. If you have run a small project past this fork, I would like to know which way you went.

Boolflow is at boolflow.site — free, no account,
nothing to install. If you want to look at the code that this post is complaining
about, it is MIT licensed.

Tell me which of these you would actually use, and I will believe you over my own
dependency graph.

Top comments (0)