A few years ago I wanted to understand logic gates properly, so I did the obvious thing and built a simulator. The result is Logic Nodes, a free logic gate simulator that runs in the browser: place gates on a canvas, wire them up, and watch signals propagate. You can read the project page for what it does; this post is about how it’s built. The decision that shaped everything else was rendering the whole editor on a hand-written HTML5 canvas instead of using a diagramming library.
For client work I would weigh that decision differently, and I’ll come back to that at the end. Building it by hand meant every rendering and interaction problem was mine to solve. These are the ones that stuck with me.
Only draw what’s on screen
The naive render loop draws every node and every wire on every frame. That works fine for the twenty-node circuits you build in week one, and falls over the first time someone builds something real. The fix is viewport culling: from the current pan and zoom, compute the rectangle of world space that’s actually visible, and skip everything outside it. The check itself has to be cheap or it becomes the new bottleneck; a bounding-box test per node was enough here, since nodes are rectangles anyway.
High-DPI kept breaking things
Retina displays render a CSS pixel as multiple device pixels, so a canvas
sized in CSS pixels looks blurry unless you scale the backing store by
devicePixelRatio and compensate in your transform. That’s the
well-documented part, and it takes an afternoon.
The real cost came later: every feature that touches coordinates has to respect the same scaling. I fixed rendering first, then discovered that loading a saved circuit broke it again, and later that the minimap’s mouse events were off on retina screens while working perfectly on my external monitor. Each fix was small; finding them was the work.
Making tiny connection points clickable
A connection point on a node is drawn as a small circle, a few pixels across. If its clickable area is exactly its visible area, wiring nodes together feels miserable, especially zoomed out or on a trackpad. So the hit test uses its own radius with a floor, instead of the drawn radius:
calcConnectionPointHitBox() {
return Math.max(this.nodeSystem.config.theme.connectionPointRadius, 5);
}
It’s most of the difference between an editor that feels precise and one that feels broken.
Getting zoom to feel right
Zoom is a feature where the math is five lines and the feel is everything. The mistake everyone notices immediately: zooming towards the center of the screen instead of the cursor. The point under your cursor should stay under your cursor while the world scales around it, and that works out to a small correction of the pan offset every time the zoom changes:
setZoom(newZoom: number, mouseX: number, mouseY: number) {
const oldZoom = this.editorState.view.zoom;
this.editorState.view = {
x: this.editorState.view.x + mouseX / newZoom - mouseX / oldZoom,
y: this.editorState.view.y + mouseY / newZoom - mouseY / oldZoom,
zoom: newZoom
};
}
The steps themselves are multiplicative: zooming in scales by a factor and zooming out divides by the same factor, so in-and-back-out returns you exactly where you started. The factor grows with the wheel delta but is capped, which keeps small trackpad deltas smooth without letting one aggressive mouse-wheel click teleport you across the circuit.
And the trackpad part is mostly knowing one browser quirk: pinch gestures
arrive as wheel events with ctrlKey set. So ctrl+wheel zooms, and a plain
two-finger scroll pans, which is exactly what your fingers expect from a
native app. When zoom comes from the keyboard or the toolbar there’s no
cursor to anchor to, so it falls back to the center of the canvas.
Adding a tick system
The first version of signal propagation just evaluated gates as fast as possible. That works for pure combinational circuits and breaks the moment you add feedback: an SR latch built from two NOR gates, or anything else with a cycle in it, depends on gates updating in a defined order at defined moments.
The fix was a tick system. Signals advance one step per tick, delay nodes hold a value for a set number of ticks, and clock nodes emit on a fixed period. Once timing was explicit, previously flaky circuits became deterministic, and counters and blinking displays became possible at all. Retrofitting it meant touching nearly every node type I had already written. I should have designed time in from the start.
Truth tables and circuit synthesis
The parts of Logic Nodes I’m happiest with took comparatively little graphics work: generating a truth table from a circuit, generating the boolean expression, and the reverse direction, constructing a working circuit from an expression or a filled-in truth table. All of that is plain TypeScript operating on the graph structure, and keeping it in plain, UI-free code is what made those features straightforward to build and test.
Would I hand-write the canvas again?
For a learning project, yes, that was the point. For a client it depends. A hand-written canvas gives you full control over performance and interaction feel, no dependency risk, and a small bundle. A library gives you a working editor weeks sooner. The deciding questions are how central the editor is to the product, and whether the interaction demands anything the library fights you on.
Logic Nodes is open source, and
the simulator is free to use at
nodes.kriyak.com. The rendering, culling, and
tick system all live in the nodesystem directory.