Engineering
Highlight the phase, not the line
The code panel lights up the line the animation is on. Then I added a Python/JavaScript toggle and a naive-vs-optimized switch that both change the code, and the same animation kept highlighting the right line in all four. The trick is that the animation never emits a line number. It emits what's happening, and each code file answers where.
Watch a lesson play and the code panel keeps pace. The canvas compares two cells, and if (seen.has(complement)) lights up. The next beat inserts, and the highlight drops to seen.set(nums[i], i). It reads like the animation is pointing at lines, the way a debugger's arrow sits in the gutter.
Then look at the top of the panel. There's a language dropdown: the same lesson in Python or JavaScript. And in the Inputs panel there's often a variant switch, naive versus optimized, that rewrites the code into a different algorithm. Flip either one mid-play and the highlight keeps landing on the right line, in code the animation has never seen. The Python version highlights line 7, the JavaScript version highlights line 8, and nothing in the animation changed. That only works because the animation isn't pointing at a line at all.
The version that pointed at lines
The first cut did exactly what it looked like. The animation, at each step, told the panel which lines to light:
emitStep({ lines: [7], phase: "insert", label: "store 3 at index 0" });The panel took lines and highlighted them. Clean, direct, and correct for exactly one thing: the single code file that shipped with the lesson. Line 7 meant line 7 of that Python. The animation and the code were welded at the line number.
The weld held right up until I wanted a second version of the code. Here's the Python for Two Sum's insert step:
if complement in seen:
return [seen[complement], i]
seen[num] = i # line 7And the JavaScript:
if (seen.has(complement)) {
return [seen.get(complement), i];
} // the brace is line 7
seen.set(nums[i], i); // line 8The insert lands on line 7 in Python and line 8 in JavaScript, because a closing brace takes a line of its own and Python's if block doesn't. If the animation hardcodes lines: [7], the JavaScript view highlights a closing brace while the actual work happens one line down. To fix that, the animation would have to know which language is showing and branch on it. Now the render loop, the thing running 30 times a second, depends on a dropdown whose entire job is cosmetic. That's backwards. The animation shouldn't know JavaScript exists.
Point at what's happening instead
So the animation stopped emitting line numbers. It emits a phase, a name for what the algorithm is doing this beat:
emitStep({ phase: "insert", label: "store 3 at index 0" });insert. Not "line 7." The animation knows it's inserting; it has no opinion about where insertion lives in your file. The line numbers moved to where they belong, onto the code itself. Every snippet carries a lineMap from phase to lines, and each answers the same question in its own coordinates:
// hashPython // hashJavascript
insert: [7], insert: [8],The panel reads the current phase off the animation, looks it up in whichever snippet is showing, and highlights whatever that snippet says. Two files, two maps, one phase stream. The animation broadcasts insert and stays out of it.
Flip the language dropdown mid-play now and there's nothing to recompute. The current phase is still insert. The panel just re-runs the lookup against the JavaScript map, gets [8] instead of [7], and re-highlights. The player never pauses, the frame clock never hears about it, and the animation goes on emitting phases into a panel that quietly changed dialects underneath it.
The variant switch is the same move, one level up
Once line numbers live in the snippet, swapping the whole algorithm is the same trick. Union-Find ships naive and optimized: the optimized find compresses the path it walks; the naive one just climbs and links two roots on a single line. Different code, different work, and the switch sits in the config panel the learner drives.
One resolver handles both axes:
selectSnippet: ({ config, lang }) => {
const byLang = SNIPPETS[config.variant]; // which algorithm
return byLang[lang]; // which language
};Two knobs feeding one function, and they're not the same kind of knob. The variant comes from the lesson's config, so it's part of the run: change it and the trace changes too, because the step generator reads the same config and produces a different sequence of phases. The language comes from a global UI preference and never touches the player. But by the time they reach the panel, both have collapsed into the same question: which of the four snippets is showing? The phase-to-line lookup doesn't care which knob moved. This is why I split the two data directions into separate contexts in the first place: the phase stream flowing out of the animation is the one wire every one of these snippets subscribes to.
What's genuinely shared across a variant's two languages is the phase vocabulary, the keys of the map. Both Python and JavaScript answer insert, probe, match. The line numbers behind those keys are private to each file. The animation speaks a fixed set of phase names; each snippet is a translator that turns those names into its own line numbers. Add a third language tomorrow and you write one more translator. You touch no animation code, because there's no line number in it to touch.
The one rule that keeps the highlight from blinking
There's a sharp edge, and it's worth stating because getting it wrong produces a bug that only shows on one path. The optimized Union-Find emits phases the naive one never does: compress, compareRank, bumpRank, the steps that only exist once you add ranking and path compression. If the naive snippet's lineMap listed those phases, they'd point at lines that aren't in the naive code. If it silently dropped them, and the naive trace did somehow emit one, the highlight would go blank for a beat.
The rule that resolves it: a snippet's map may be silent on a phase its trace never reaches, and must answer every phase its trace does. The naive map simply omits compress and friends, which is safe precisely because the naive trace never sends them. The two halves, the generator and the map, have to agree on the phase alphabet for the path they share. That agreement isn't something you can eyeball across four files, so a guard test checks it: for every phase a trace emits, the active snippet's map must have a line for it. A blank highlight isn't a cosmetic glitch. It's the code panel admitting it lost track of what the animation is doing, and on a teaching tool that's the one thing it can't do.
What the indirection bought
Pointing at a line number felt like the honest, literal thing, and it cost me the moment a lesson needed to be two things at once. Pointing at a phase felt like a layer of indirection I hadn't earned yet, and it turned out to be the layer that made the panel modular. The animation names what's happening. Each code file, in each language, for each variant, says where that happens to live in it. Because the animation refuses to count lines, one render loop drives four files it will never read, and adding a fifth is a code.ts change nobody upstream has to hear about.