Ongoing

CircuitSim - Logic Circuit Editor & Simulator

Creator & Solo Developer · 2026 · since August 2026 (ongoing) · 1 person · 11 min read

Built a production-grade logic circuit editor and simulator in Java 17 / libGDX: deterministic tri-state simulation with feedback, user-defined custom components, full sandbox, and an 89-challenge campaign that walks you from a single inverter all the way to a complete 4-bit CPU with its own instruction set - ~57k lines of code, fully tested.

Overview

CircuitSim is a desktop editor and simulator for digital logic circuits. You place gates, wire them with multi-bit buses, and watch signals propagate in real time with deterministic tri-state (LOW / HIGH / UNKNOWN) semantics, combinational feedback and sequential (stateful) components. On top of the free sandbox, a full campaign takes you step by step - through latches, adders, decoders and memory - until you assemble and drive a complete 4-bit CPU core executing instructions from ROM data.

Problem

Classic digital logic education (Nand Tetris style) is powerful but usually limited to a fixed set of labs. I wanted a tool where the sandbox and the learning path coexist: a real editor you can explore freely (including building your own reusable components), and a structured campaign that progressively builds from basic gates to a working CPU. No existing tool combined deterministic feedback handling, user-defined components, a full campaign, and a polished real-time rendered editor.

Constraints

  • Deterministic simulation: feedback loops and stateful components must behave predictably
  • Real-time rendering of a large circuit with zero allocations in the hot loop
  • GLES2-only graphics target (no fancy pipeline APIs)
  • Content (89 challenges, narrative, guides) must be data-driven and localizable (FR/EN)
  • Solo development: architecture had to stay maintainable over ~57k lines of Java

Approach

Strict separation between the logic domain and rendering: renderers only read immutable snapshots, never mutate state. All circuit mutations flow through a single Circuit API with three revision counters (logic / topology / spatial) that drive targeted invalidation instead of full recomputation. The campaign is fully JSON-driven (challenges, scenes, objectives) so content and localization stay outside the code. The whole thing ships as two Gradle modules: core (domain, simulation, editor, saves, campaign) and lwjgl3 (desktop launcher, natives, packaging).

Key Decisions

Immutable SignalSnapshot as the single interface between simulation and rendering

Reasoning:

The graph is evaluated once per logic revision into an immutable snapshot with identity-keyed lookup tables; every renderer, the HUD and the save capture read from it. No renderer ever computes logic, which keeps determinism testable in isolation and eliminates an entire class of render/logic desync bugs.

Alternatives considered:
  • Renderers reading mutable circuit state (tight coupling, flaky invalidation, hard to test)
  • Event-driven render updates (per-move messages, more state to keep in sync)

Three revision counters (logic / topology / spatial) for targeted invalidation

Reasoning:

A signal change only bumps logicRevision; adding/removing components bumps topologyRevision; moving or rotating bumps spatialRevision. Each consumer (simulation memo, spatial index, scene cache) only rebuilds when its own revision changes - moving a gate does not trigger a logic re-evaluation, and changing a wire does not rebuild the spatial index.

Alternatives considered:
  • Global dirty flag with full recompute (correct but wastes frames on large circuits)
  • Manual invalidation calls scattered across the codebase (easy to miss one, subtle bugs)

Custom components compiled to stable serialized definitions in a behavior registry

Reasoning:

Players can encapsulate any sub-circuit into a reusable, savable, archivable component. Compilation produces a definition with stable identity and boundary pins registered by serialized ID - adding a new component type never touches a central switch and never breaks saved campaigns. Circuit boundaries are evaluated inside the same simulation graph, so custom components compose recursively.

Alternatives considered:
  • Subclassing a ComponentBehavior per type (doesn't scale to user content)
  • Black-box lookup tables (impossible for arbitrary combinational circuits)

Deterministic tri-state core with feedback resolution and oscillation detection

Reasoning:

UNKNOWN propagates through the graph just like LOW/HIGH; combinational cycles are treated as inactive signals, stateful components (latches, registers) hold values across evaluations, and sustained oscillation is detected instead of freezing the loop. This is what makes building real sequential logic - and eventually a CPU - safe to play with.

Alternatives considered:
  • Boolean-only simulation (no reset state, no undefined behavior modeling)
  • Time-step delta simulation (heavier to get right, overkill for interactive editing)

JSON-driven campaign: challenges, scenes and objectives as data

Reasoning:

Each challenge declares its ports, allowed components, constraints and success vector as pure JSON, with all display text keyed into FR/EN catalogs. 89 challenges and 179 narrative scenes ship as content, not code - adding or rebalancing a level never recompiles Java, and the whole campaign is translatable without touching logic.

Alternatives considered:
  • Code-defined levels (hardcoded in Java, translation scattered across sources)
  • Scripting language for levels (more power, more attack surface, overkill)

Async save worker with monotone commit tokens

Reasoning:

Auto-save captures are budgeted to ~0.75 ms per frame on the main thread and handed to a daemon worker that coalesces pending autosaves (1 s debounce, 30 s max interval) and writes atomically via temp file + rename. A process-wide coordinator assigns monotone tokens so a stale in-flight write can become a durable fallback but can never overwrite a newer commit - no torn saves on crash or exit.

Alternatives considered:
  • Synchronous saves (frame hitches on every autosave)
  • Last-write-wins by file time (stale writes can corrupt the newest state)

Custom GLES2 post-processing pipeline with adaptive quality

Reasoning:

A hand-rolled Dual-Kawase bloom (emissive extraction in linear light, down/up pyramid, tone-mapped composite), analytic SDF grid and HUD compositing, all targeting GLES2 only. An adaptive quality controller degrades HIGH -> BALANCED -> ECO using frame-time hysteresis (120 slow frames to downgrade, 300 fast to upgrade, 8 s cooldown), and if allocation fails the world falls back to direct rendering - the game never fails to start.

Alternatives considered:
  • MSAA (no bloom, heavier on fill rate)
  • Fixed quality profile (breaks on weak GPUs, no escape hatch)

Tech Stack

  • Java 17
  • libGDX
  • LWJGL3
  • GLES2
  • Gradle (multi-module)
  • JUnit 5

Result & Impact

  • ~57,000 lines of Java
    Codebase Size
  • 71 test files across domain, editor, saves, campaign
    Test Coverage
  • 89 challenges · 179 narrative scenes · FR + EN
    Campaign
  • ~0.75 ms/frame, atomic commits
    Save Capture Budget

A complete, polished desktop product: a deterministic simulator that safely handles feedback and state, a true sandbox where any sub-circuit can be promoted to a reusable custom component, and a full authored campaign taking the player from a first inverter to a 4-bit CPU core - datapath, instruction register, control unit and sequencer driven by external ROM data. The refactored architecture (snapshot-based, revision-driven invalidation, budgeted saves) holds up to large circuits without frame hitches. A Steam release is being prepared.

Learnings

  • The single most important invariant: renderers read snapshots, never compute logic. Every rendering bug since then has been a rendering bug, never a simulation bug
  • Three narrow revision counters beat one global dirty flag: targeted invalidation is what keeps large scenes at full frame rate
  • Budgeted capture on the main thread + monotone commit tokens gave crash-safe saves without ever blocking the frame loop
  • Hysteresis is non-negotiable for adaptive quality - without it the pipeline oscillates between profiles
  • Making campaign content pure JSON turned 'adding a level' from an engineering task into an authoring task
  • Dual-Kawase bloom in linear light is the sweet spot for a glow-on-logic aesthetic on a GLES2-only stack

What is CircuitSim?

CircuitSim is a desktop editor and simulator for digital logic circuits, built with Java 17, libGDX and LWJGL3. You build circuits from gates (AND, OR, XOR, NOT), wire them with multi-bit buses (3, 4 and 8 bits), and watch signals propagate in real time. It’s a full product, not a lab exercise: a free sandbox, a user component system, an in-game electronic guide, and a complete authored campaign.

A Steam release is being prepared - no public build available yet.

The Simulation Core

The heart of the project is a deterministic tri-state simulation engine:

  • Three signal states: LOW, HIGH and UNKNOWN. Undefined inputs (like a latch before its first set) propagate as UNKNOWN instead of being silently coerced to a value.
  • Combinational feedback: feedback loops are a first-class feature, not a crash. Signals depending on a combinational cycle are treated as inactive, and sustained oscillation is detected rather than freezing the evaluator.
  • Sequential logic: latches and registers hold state across evaluations, which is what makes building memory - and eventually a CPU - possible.
  • Multi-bit buses: splitters and mergers for 3, 4 and 8-bit buses let you model real word-width datapaths.
  • Recursive composition: the boundaries of custom components are evaluated inside the same simulation graph, so a component containing a component containing a latch all resolves in one deterministic pass.

The evaluation is memoized per logic revision into an immutable SignalSnapshot - the graph is walked once, then every consumer (rendering, HUD, save capture) reads from identity-keyed tables with no re-traversal.

Custom Components & the Sandbox

The sandbox is total: any arrangement of gates can be encapsulated into a custom component with its own boundary pins, saved to disk, archived and reused anywhere in the campaign. Compilation produces a definition with a stable serialized identity registered in a behavior registry - so:

  • Adding a component type never touches a central switch.
  • Saving a campaign never breaks when the simulator evolves.
  • Your adder, your latch, your ALU are first-class parts of the builder palette.

This turns the sandbox into a genuine design tool: build it, name it, drop it in another circuit, archive it for later.

The Campaign: From an Inverter to a 4-bit CPU

The campaign is the spine of the game - 89 challenges, 179 narrative scenes, fully written in French and English, all data-driven JSON (ports, allowed components, constraints, success vectors per challenge). The progression arc:

  1. Signal fundamentals - inverters, gates, bus splitting, first memories
  2. Combinational design - half/full adders, 2-bit and 4-bit adders, decoders, selectors
  3. Sequential logic - latches, edge-triggered memory, command registers, flag registers
  4. The CPU arc - instruction format, opcode decoding, fetch cycle, fetch address/advance, datapath, control unit, sequencer - culminating in assembling the complete 4-bit CPU core: datapath + instruction register + control unit + sequencer, driven by external ROM data, with live PC, MAR, IR and register outputs.

You literally build the CPU from gates you’ve been assembling all game. Every challenge runs in an isolated workspace with exactly the components it allows, so you can’t cheat past the lesson.

Architecture

Two Gradle modules, strict layering:

circuit/
├── core/    # domain, simulation, editor, saves, campaign, i18n
│   ├── component/      Circuit, components, pins, connections, revisions
│   ├── simulation/     SignalSnapshot, behaviors, feedback resolution
│   ├── editor/         interactions, EditorCommand, renderers, pipeline
│   ├── save/           serialization, autosave worker, custom components
│   ├── campaign/       JSON loading, progression, narrative, objectives
│   └── i18n/           UI, guide and campaign catalogs (FR/EN)
└── lwjgl3/  # desktop launcher, natives, packaging

Key invariants that keep 57k lines maintainable:

  • One mutation path: all structural changes flow through the Circuit API, which validates placement collisions, pin direction, wired-input uniqueness and component ownership.
  • Reversible editor commands: placement, wiring, deletion and (group) moves are EditorCommands with a bounded 256-deep history - undo/redo can never bypass the domain API.
  • Three revisions, three consumers: logicRevision → simulation memo, topologyRevision → scene caches, spatialRevision → the spatial index. Each subsystem only rebuilds when its revision changes.
  • Rendering is a projection: the render pipeline reads snapshots and reusable render frames. The world state is never inspected from a shader or draw call.

Performance engineering

  • Spatial index in 16-cell chunks, rebuilt only on spatial/topology revisions, indexing each segment of an orthogonal cable (not its bounding box); the same index powers pixel-stable pin/cable hit-testing.
  • Zero-allocation hot path: reusable mesh caches for gate geometry, per-identity render contexts updated in place during drags, LOD that drops pins below 9 px/cell and text below 18 px.
  • GLES2 post-processing: Dual-Kawase bloom with emissive extraction in linear light, analytic SDF grid, HUD composited after the bloom for legibility - with an adaptive quality governor (HIGH / BALANCED / ECO, hysteresis-based) and a hard fallback to direct rendering.
  • Crash-safe saves: budgeted ~0.75 ms/frame capture (aborted cleanly if the revision changes mid-capture), daemon worker with coalesced autosaves, temp-file + atomic rename, and monotone commit tokens so a stale write can never clobber a newer commit.

All of it is pinned down by 71 JUnit 5 test files covering revisions, memoization, cycles, undo/redo, spatial culling, save ordering/coalescence and screen lifecycle.

What I Learned

  1. Own the boundary between logic and pixels. The snapshot API is the single decision I’d make first in any real-time editor: it made the simulation trivially unit-testable and the renderer a dumb projection.
  2. Invalidate narrowly. Three small revision counters bought more frame-time headroom than any micro-optimization - the index, the caches and the simulation each rebuild only when they truly must.
  3. Async I/O needs ordering guarantees, not just threads. The monotone token coordinator is 100 lines that made “never lose the newest save” a provable property instead of a hope.
  4. Content as data. Once challenges and text became JSON, the campaign could grow from 10 to 89 challenges without a single Java change - and localization became a catalog, not a refactor.
  5. Budget everything that touches the frame. Save capture, camera drags, index rebuilds - if it can run for 2 ms today, budget it for a 200-component circuit tomorrow.

Why This Project Matters

CircuitSim is the deepest solo systems project I’ve done: a deterministic simulation engine with real semantics (tri-state, feedback, sequential state), a full editor architecture (commands, revisions, spatial indexing), a production graphics pipeline on a constrained API (GLES2), a crash-safe persistence layer, and an entire authored game experience on top - ending with the player building and running a 4-bit CPU from first principles.

It demonstrates the full arc from formal core (the simulation) to shipped product (the campaign, i18n, adaptive rendering), with a test suite that keeps every layer honest.