All posts

Engineering

I built a debugger, not an animator

Most algorithm visualizers play you a movie. VizLearn pins the code, the visual stage, and the log to the same instant — a trace debugger for learning an algorithm, not a clip to watch. This is the architecture I ended up with after chasing that alignment through the implementation.

February 26, 20268 min read

Most algorithm visualizers are, when you look closely, a kind of movie player. You press play, bars slide around, a colored cell lights up, and at the end something has happened on the screen. Many of them are genuinely beautiful, and for a first impression they do the job. The gap is what's left over: the code — the thing you actually came to understand, and why each step happens — tends to sit underneath the show as a caption.

The version of VizLearn that kept working starts from a different guess about what makes an algorithm click. The thing that seemed to help was not the animation by itself; it was the alignment. The moment that teaches you bubble sort is the one where you can see, at the same instant: the two bars being compared on the visual stage, the exact if a[j] > a[j+1]: line highlighted in the code, and a log line that reads "compare a[0]=5 and a[1]=2." One semantic moment, narrated three ways.

That's closer to a debugger than a movie — specifically a trace debugger for learning an algorithm. To be precise about the analogy: you're not setting breakpoints, editing the code, or unwinding a live call stack. You're scrubbing a recorded execution and reading it from three angles at once. This post is about the architecture that keeps those three angles in lockstep without the animation's render loop dragging the whole UI down with it — and about one small discipline that decides whether the result feels honest or feels faked.

The problem with one big state blob

The naive version is a single store. The animation writes "current step is 7," every panel subscribes, everybody re-renders. It works for about a week.

The trouble is that these surfaces update on completely different clocks. The visual stage redraws on every frame — thirty times a second, interpolating bar positions so the motion is smooth. The code highlight and the log change only at step boundaries — a couple of times a second at most. If the code panel subscribes to the same state the animation mutates per frame, you are re-rendering a syntax-highlighted code block thirty times a second to change nothing. The config panel, sitting off to the side, gets invalidated on every frame too. Now scrubbing the timeline feels like wading through mud, and you start reaching for memo everywhere to paper over a problem you designed in.

The fix isn't more memoization. It's noticing that the data flows in two directions, and they should not share a channel.

Two directions, two contexts

VizLearn splits the player's state by direction of flow.

One context is the UI → animation channel. The config panel writes to it: the array you typed, the speed you picked, the algorithm variant you toggled. The animation reads from it. This changes rarely — only when you do something.

The other context is the animation → UI channel. The animation writes to it once per step boundary, through a single call:

// fired once when the animation crosses into a new step
emitStep({
  lines: [2],                          // highlight `if a[j] > a[j+1]:`
  phase: "compare",
  label: "compare a[0]=5 and a[1]=2",   // the log line for this beat
});

That one call atomically updates three things: which code lines are highlighted, the status label, and a new entry appended to the log (deduped, so a step that emits twice doesn't stutter the log). The code panel and log subscribe here — and crucially, not to the per-frame render state. The animation's thirty-times-a-second position updates live in the Remotion render tree and never touch React's context at all.

So the expensive surface (the stage) updates per frame in isolation. The cheap surfaces (code, log, status) update a couple of times a second when emitStep fires. The config panel, on its own channel, doesn't re-render when the animation plays at all. The lockstep is real — all three describe the same step — but nothing pays for alignment it doesn't need.

The whole thing is maybe forty lines of context boilerplate. The design isn't clever; it's just aimed. Splitting by direction instead of by feature is the entire trick.

Frames are the source of truth, not a side effect

Here's the part that keeps the debugger honest.

A tempting way to animate is to fire timers: "highlight line 2, wait 400ms, swap the bars, wait 400ms, highlight line 3." It animates fine. But the state now lives in the timeline of side effects — and the moment you let the user scrub backward, or jump to step 5, or replay at half speed, that house of cards collapses. Where are the bars at step 5? You'd have to replay every timer to know.

Instead, every VizLearn animation is a pure function of one number: the current frame. The step generator runs first, ahead of any rendering — it walks the algorithm over your input and produces a deterministic array of steps, each a complete snapshot. The renderer then asks, "we're on frame 212; which step is that, and how far into it?" and draws accordingly:

// the whole render, conceptually: no timers, no accumulated state
const i = Math.floor(frame / FRAMES_PER_STEP);       // which step
const t = (frame % FRAMES_PER_STEP) / FRAMES_PER_STEP; // 0→1 within it
draw(steps[i], t);            // steps[i] is a precomputed snapshot

Scrubbing to frame 212 always produces the same picture, because the picture is computed from 212, not accumulated toward it.

The real arithmetic is a little more involved than that divide. Not every lesson gives every step the same number of frames — some beats need room to breathe, so a lesson can hand the player per-step durations, and the frame-to-step mapping becomes a lookup against precomputed step-start frames instead of a flat frame / FRAMES_PER_STEP. That part doesn't change: a frame still maps to exactly one step and a known offset into it, with nothing accumulated along the way.

This is why the step generators are plain, pure TypeScript with no React and no Remotion in them — and why they're the most heavily tested files in the codebase. A frame-level visual bug is cosmetic. A step generator that mis-simulates the algorithm teaches you the wrong thing, and no amount of pretty motion undoes that. So that logic gets unit tests for the boundaries that mislead a learner if they break: the empty input, the duplicate keys, the final write, the target that isn't there.

Snap the meaning, glide the position

One last discipline, and it's the one that separates "honest" from "faked."

Within a single step, some things should change instantly and some should move. Get this backwards and the animation lies to you. After a few versions that felt fake, the habit I kept was this: a step's semantic state snaps at its boundary; the transition into it animates over the step's frames.

Concretely: when bubble sort decides two bars are being compared, their color becomes amber immediately on the step's first frame — because "these two are being compared" is a fact, and a fact that fades in over 300ms is a fact you can misread mid-fade. But when two bars swap, they don't teleport. Their positions interpolate across the step, because position is continuous and your eye needs to track which bar went where.

const color = step.comparing ? AMBER : IDLE;   // snaps: it's a fact
const x = lerp(prevX, step.x, easeInOut(t));   // glides: it's a position

The subtle case — the one that's easy to get wrong — is the code highlight. It's tempting to treat "line 2 is highlighted" as a state that snaps. But the row a highlight sits on is a position. So the highlight glides from line 2 to line 3, the same way a sorting pointer glides between indices. Snap the color, glide the row. Once you see highlights as coordinates rather than states, a dozen little "why does that feel janky" questions answer themselves.

The implementation kept pointing back

Every one of these decisions kept pointing back to the same practical constraint: the animation IS the explanation. If I'm going to treat it that way, then the alignment has to be exact (or you're teaching a lie), the state has to be reconstructible from a frame number (or you can't let the learner scrub freely through it), and the motion has to distinguish facts from positions (or the learner mistrusts what they see). The architecture is what was left after taking that constraint seriously.

VizLearn is a debugger that happens to be beautiful. The bars are nice. The lockstep is the point. And the point of the lockstep is what it lets me do next: stop the clock and make you predict the next step before you scrub to it, with one honest answer already waiting to grade you against. The alignment is the floor; predicting on it is the lesson.

See it for yourself: open bubble sort, type a new array, then scrub backward and watch the same code, log, and visual state reconstruct themselves from the frame — exactly as described above.