Circuits — draw one, and it works
The circuit kit takes a netlist and gives you a schematic that draws itself, carries real current, and answers questions. The numbers are not painted on: every voltage, current and glow comes from a Modified Nodal Analysis solve of the circuit you typed, pre-simulated at build time so it scrubs and records exactly.
Three ideas:
- Type the circuit —
circuit(id, center, netlist) - Play it —
run(id, seconds) - Take something out —
cut(id, part)/reconnect(id, part)
canvas("16:9"); template("paper");
circuit(rc, (640, 360), `
dc-voltage 0 4 0 0 v=5
resistor 0 0 5 0 r=1k
capacitor 5 0 5 4 c=1u
wire 5 4 0 4
`);
probe(rc, (5, 0));
run(rc, 6);
That block draws a schematic one component at a time, starts the current the moment the loop closes, and reads the capacitor charging to 63.2% of the supply at exactly one time constant — because it solved an RC circuit.
The netlist
One component per line. Two grid points, then optional key=value parameters:
<type> <x1> <y1> <x2> <y2> [key=value …]
ground <x> <y>
# a comment
The topology comes from the geometry. Points that coincide — or are joined by
a wire — are the same electrical node. There are no node numbers to write and
none to get wrong:
circuit(divider, (640, 360), `
dc-voltage 0 6 0 0 v=9
resistor 0 0 4 0 r=3k # these two share the point (4,0),
resistor 4 0 4 6 r=1k # so that IS the divider's tap
wire 4 6 0 6
ground 0 6
`);
Values take SI suffixes — 1k, 4.7u, 100n, 10m, 2M. Lowercase m is
milli and M is mega (SPICE’s habit of writing mega as M is a trap, so it is
not one here).
ground is a reference, not a component
The solver needs one node to measure everything else from. With no ground line
the kit elects the first source’s negative terminal, so a single loop solves
identically either way — the symbol adds nothing and a textbook figure of a cell
and a lamp does not draw one. Write a ground when several sources or loops make
it worth saying which node is zero, or when the schematic’s convention wants the
symbol.
A three-pin part still takes two coordinates
Its extra pins are derived, and this is the one thing worth memorising:
| part | pins |
|---|---|
transistors, opamp, gates, comparator, vco, ideal-switch, spdt-switch | third pin one step below the second coordinate |
| flip-flops | two outputs, one and two steps below the second coordinate |
relay | third below the second, fourth below the first |
transmission-line, dependent sources | output pair below both written points |
circuit(amp, (640, 360), `
bjt-npn 3 3 6 3 # base (3,3) · collector (6,3) · EMITTER (6,4)
…
`);
If a pin ends up connected to nothing, the error names it and its coordinate rather than saying “the circuit will not settle”.
It draws itself
A netlist is already written in the order a teacher would draw the circuit, so that is the default: components arrive one at a time, traced on, and each prefix of the netlist is really solved.
So nothing flows while the loop is open — the charge dots do not move because
there is nothing to move them — and the instant the closing component lands,
current appears everywhere at once. A probe reads 0.00 mA and then the true
value. Nobody animates that.
The sixth argument is the share of run spent drawing:
circuit(rc, (640, 360), `…`, 46, 1, 0.35); // the default
circuit(rc, (640, 360), `…`, 46, 1, 0); // already drawn at t = 0
Use build 0 when the first frame matters — a film’s opening shot, a thumbnail —
because a self-drawing circuit is invisible until a run plays it. (The
editor warns if you forget the run.)
Reading it: probes and scopes
probe puts a live number on the schematic. Point it at a grid point for that
node’s voltage, or name a part for its current:
circuit(rc, (560, 340), `
dc-voltage 0 4 0 0 v=5
resistor 0 0 5 0 r=1k name=R1
capacitor 5 0 5 4 c=1u
wire 5 4 0 4
ground 0 4
`);
probe(rc, (5, 0)); // volts at the capacitor's top
probe(rc, R1, (80, -20)); // amps through R1, label nudged clear
scope(rc, (5, 0), (1020, 300), 380, 150);
run(rc, 6);
A scope traces the pre-simulated waveform with a sweep line that keeps step
with run, and labels both axes with their real extent. Its window belongs to
the circuit it was declared against — after a cut, declare another one or use a
probe, which does follow across.
The current you can see
Charge dots ride every branch. Their position is ∫I dt — accumulated charge
— from the same branch currents the solver produced, so a branch carrying twice
the current always moves twice as fast, and a dot and a probe can never disagree.
current is the animator’s dial. It changes nothing about the physics:
current(rc, 2); // twice the pace
current(rc, 1.4, diamond, crimson, 4); // speed, shape, colour, size
speedmultiplies every branch by the same factor, so the proportionality survives — 1 is the circuit’s own pace, 2 is a fast cut, 0.5 is one you can talk over.shapeiscircle(default),squareordiamond.colortakes any palette name or#rrggbb. The default is gold, the conventional-current colour; cyan is the usual choice for electron flow.
A branch with no current shows no dots at all, rather than a frozen row of them — a stalled queue of dots reads as current, which is the one misreading worth designing against.
A lamp glows because it is dissipating power
lamp and led carry a halo whose opacity is P / P_ref, with P = V·I from
the same series a probe reads. Two lamps in series share one current, so they
light equally; a 300 Ω lamp in series with a 100 Ω one comes out three times
brighter, because at one current P = I²R.
The reference is fixed by the circuit as first built, so brightness is comparable
across the schematic and a cut genuinely darkens it. The halo is an ordinary
entity tagged {id}.glow:
color(fig.glow, orange); // a warm glow reads as light on `paper`
Take something out, and mean it
cut removes a component from the circuit, not from the picture: what is left
is re-analysed and re-solved from scratch. Break a series loop and the current
stops everywhere, and a meter on another branch falls to zero on its own.
run(fig, 6); // both lamps lit
cut(fig, L1, 0.8); // one filament breaks
run(fig, 3); // … and neither glows
reconnect(fig, L1); // mend it
run(fig, 3); // both back
Address the part by its netlist name=, or by position as c0, c1, … Each
run after a change replays the circuit that now exists. A cut that leaves
something unsolvable (cutting the only source) is refused, with the part named.
cut decides the physics; you decide the theatre. By default the component
fades out with its label, every dot and glow goes with it, and each probe moves to
what it now reads. If the story is a broken filament rather than a lamp taken
out of its holder, bring the symbol back and mark the break:
cut(fig, L1, 1.0);
par {
show(fig.L1, 0.4); // the lamp is still there …
show(break1, 0.35); // … it just cannot carry anything
}
Everything is an ordinary entity
The kit adds no animation vocabulary. Every piece is a normal tagged entity, so the core verbs work:
| address | what it is |
|---|---|
{id}.c{k} | the k-th component, in netlist order |
{id}.<name> | a component you gave name=X |
{id}.parts | every component (stroke batch) |
{id}.labels | the value labels |
{id}.nodes · {id}.n{node} | junction dots |
{id}.charge | the charge dots |
{id}.glow | lamp/LED halos |
{id}.probes · {id}.scopes | readouts |
{id}.<type>s | every part of one type — {id}.resistors, {id}.lamps |
framebox(ring, fig.L1, 10); // ring the component you are talking about
par { zoom(1.7, 1.2); cam((820, 420), 1.2); }
pulse(fig.L2);
color(fig.resistors, indigo);
erase(fig.parts, 0.4); // erase the STROKES …
fade(fig.labels, 0.3); // … and fade what is not a stroke
That last pair is the one gotcha: erase/draw are stroke verbs, so on a bare
circuit id they leave the labels and dots behind. Address {id}.parts and fade
the rest.
What is in the kit
44 component types, from the 102-preset library the solver was ported from:
| group | types |
|---|---|
| passive + sources | wire ground resistor capacitor polarized-cap inductor dc-voltage ac-voltage dc-current clock lamp |
| switching | switch push-switch spdt-switch ideal-switch relay fuse |
| junctions | diode led zener |
| transistors | bjt-npn bjt-pnp mosfet-n mosfet-p jfet-n jfet-p darlington-npn darlington-pnp |
| analogue | opamp comparator schmitt vco transmission-line |
| combinational logic | and-gate or-gate nand-gate nor-gate xor-gate not-gate logic-input logic-output |
| sequential logic | d-flipflop sr-flipflop jk-flipflop |
Junctions, transistors and op-amps are solved by Newton–Raphson with voltage limiting; capacitors and inductors use trapezoidal companion models; a Darlington really is two transistors sharing an internal node, and a transmission line really is a ladder of L–C sections, which is why a step arrives late.
The physics you can rely on
- KCL and KVL hold by construction. Every node row of the matrix is a current balance and every voltage source adds a row that pins its branch, so any solution the kit accepts satisfies both to solver precision.
- The timestep is the circuit’s, not the frame rate’s. It integrates at the smallest time constant the circuit demands and decimates the output down to playback. Coarsening the integration would change the answer; coarsening what is stored cannot.
- It is pure in
t. The whole transient is solved at build time, so scrubbing backwards — including back through a construction or a cut — is exact. - Closed-form checks, in the test suite. Ohm’s law, series/parallel, a
divider’s tap, an RC step at 63.2% of one time constant, RL rise, series-RLC
resonance at
1/(2π√(LC)), a diode’s forward drop, a half-wave rectifier’s floor, β² gain on a Darlington, a 1 µs delay line arriving at 1 µs.
End to end: a class-9 question
“If the filament of one of the lamps is broken, will the other glow? Justify your answer.” The answer is nowhere in this file — it comes out of the solver.
canvas("16:9"); template("paper");
circuit(fig, (560, 330), `
lamp 1 0 5 0 r=120 name=L1
lamp 5 0 9 0 r=120 name=L2
wire 9 0 9 4
wire 9 4 6 4
dc-voltage 6 4 4 4 v=6
wire 4 4 1 4
wire 1 4 1 0
ground 6 4
`, 62, 0, 0);
current(fig, 1.4, circle, crimson, 4);
color(fig.glow, orange);
probe(fig, L1, (-14, -52));
run(fig, 6); // one current, so both lamps light equally
cut(fig, L1, 1.0); // break one filament
run(fig, 4); // both dark, and the meter reads 0.00 mA
reconnect(fig, L1, 1.0);
run(fig, 4); // mended
A series circuit is one path. Break it anywhere and you have broken it everywhere — and here that sentence is a measurement, not a claim.
See the circuits gallery for the full scenes.