---
title: "I wanted a dependency, not an engine"
description: "I could make Motion Canvas play, and even seek — I wrote a player that did. The trouble was that 'step 7' isn't a timestamp; it has to drive the animation, the code highlight, the log, and the input at once. Chasing that mismatch down, I rebuilt Motion Canvas in 679 lines of my own — then deleted it for Remotion."
source: https://vizlearn.app/blog/dependency-not-an-engine
date: 2026-03-11
---

# I wanted a dependency, not an engine

There's a particular mistake where you're so close to the thing you want that you decide to build the last ten percent yourself, and the last ten percent turns out to be the whole iceberg. Mine started with one small, reasonable need: let a learner stop on step 7 of a bubble sort and read the line of code that put it there. Before VizLearn ran on Remotion, it ran on [Motion Canvas](https://motioncanvas.io/), and I want to be careful about what actually went wrong, because the lazy version is "Motion Canvas couldn't do it." It could. I made it play; I wrote a whole separate player that let me seek and step through frames. The trouble was quieter than a missing feature — and chasing it one honest move at a time is how I ended up, one December, having written my own animation engine: 679 lines, a scene graph, a timeline, a player, a renderer. It worked. I threw it away.

This is the story of that detour — why one product idea became a from-scratch engine, what building it taught me that no documentation could have, and why "I've basically rebuilt my own tool" is the loudest possible signal that you picked the wrong one. It's the wreckage underneath a calmer post, [I wanted a component, not a studio](https://vizlearn.app/blog/component-not-a-studio): that one is the decision, told from the far side. This one is the month I spent not making it.

## A step isn't a moment in time

Motion Canvas animates by yielding time inside a generator — you write `yield* bar.position.y(top, 0.4)` and it reads like a screenplay. It's a lovely way to author a video, and I've given it its full due [elsewhere](https://vizlearn.app/blog/component-not-a-studio). The mismatch with VizLearn was never on the playback side. It's that VizLearn is [a debugger, not an animator](https://vizlearn.app/blog/debugger-not-an-animator), and in a debugger "step 7" is not a point on a clock. It's a piece of *product state*. At the instant of the seventh comparison the stage shows the compared pair, the code panel lights up `if a[j] > a[j+1]`, the log gains a line, and the learner can stop, change the input, and re-run from there. "The current step" has to drive several surfaces at once and be a value the rest of a React app can hold, read, highlight from, and reset.

That is the part an authored generator scene fights you on, and it isn't playback. I could make Motion Canvas play, and the separate player I wrote could seek and step, so "it can't seek" was never the real problem. The problem is that in a generator the timeline *is* the execution: the current state lives as a side effect of how far the function has run, not as a value you can lift out and wire into a code panel, a log, and an input box. Every time I tried to make "the current step" a first-class thing the product could build on, I had to reach deeper into the runtime to drag it back out. The boundary was in the wrong place for what I was building. I couldn't see that yet, so I kept reaching — and the reaching is the rest of this post.

## One idea became an engine

The marker idea is small and completely reasonable: tag a moment with a label and a time. `addMarker("step1", t)`. Then `nextStep()` finds the next marker past the playhead, plays forward, and auto-pauses the instant it arrives — gliding to the step, not teleporting to it. Useful. Harmless. A weekend.

Except a marker is only worth anything if I own the playhead, and Motion Canvas owns its playhead — which is exactly why I'd already built that separate player — not a shell around the official one, but a from-scratch reimplementation, reverse-engineered from Motion Canvas's source and compiled bundle with an AI's help, so play and seek were mine to own. And owning the playhead is only worth anything if I can ask the scene for its complete state at an arbitrary time — which means the timeline has to be *data I control*: a list of clips, each one "this property, on this node, from this value to that one, over this span, with this easing." So I wrote a timeline. And to fill that timeline from the nice generator syntax, I needed something to run the generator and compile its yields into clips — turn `yield* rect.to({ x: 500 }, 1)` into a clip, handle `all(...)` by branching child threads and taking the longest, handle `waitFor`, handle `marker`. So I wrote that too, an interpreter that walked the generator and emitted the timeline.

Stop and look at what that pile of nouns is. A scene graph. A thread that compiles a generator. A timeline of clips. A player with play, pause, reset, reverse, speed, and step navigation. Easing functions. Color interpolation. Parallel composition. I had reimplemented Motion Canvas — with one extra feature bolted to the side, markers, the entire reason I'd started. Those 679 lines were no longer a player sitting on top of Motion Canvas. They were a second, worse Motion Canvas, written by someone who'd been using the real one for six weeks, now quietly competing with a project that had been at it for years.

## The renderer fought me on the same day

And the timeline was only half of it, because the shapes had to look hand-drawn. That was [Rough.js](https://roughjs.com/) from day one — a layer with [its own story](https://vizlearn.app/blog/paintbrush-not-a-drawing-app). My engine drew to SVG, because SVG is the obvious DOM-native target and roughjs can output to it. That was a second mistake stacked on the first.

roughjs is, underneath, a canvas-first library, and driving it to SVG bites you twice. First, the fill. A hand-drawn fill is a bundle of little sketchy strokes, and on SVG they overshoot the outline — the hatching pokes out past the edge of the box and the whole thing looks like it was colored in by a kid in a hurry. The fix is a clip path per shape: define the rounded-rect outline as a clip region, draw the fill, clip it back inside the lines. My own engine's comment from the time is still sitting in the file — a note I left myself to clip the fill so the hand-drawn hatching never escapes the box outline. I had very obviously just been burned by exactly that and written myself a warning in the wound.

Second, roughjs hands SVG back as a *drawable* you have to walk yourself — a list of move/curve/line ops — and translate into `<path>` data by hand. So every single shape was: build the outline path, generate a fill drawable, generate a stroke drawable, walk both op-lists into `<path>` elements, wrap the fill group in a clip path, append the lot. Now animate it. Every frame, every property change, the shape tears all of that down and rebuilds it — defs, clip path, fill group, stroke group, a dozen path nodes — and discards last frame's. A stage with thirty cells at thirty frames a second is a bonfire of SVG nodes. It ran. It spent most of its life creating and destroying DOM to slide a rectangle three pixels to the left.

## It worked. That was the problem.

By the end of December the thing was real. You could press play and watch a bubble sort, scrub backward, step forward marker by marker, and see a syntax-highlighted code panel track the animation, all in the hand-drawn style. In miniature, it did what VizLearn does today.

So why is it in an archived repo instead of in production? Because a working homemade animation engine is the most expensive thing you can own. It works, so you keep it, and it taxes you forever. Every bug in the tweening is mine to chase. Every edge case in the generator compiler is mine. The clip-path-and-rebuild scaffolding is mine to maintain. And none of it is the product. The product is the lessons — bubble sort taught well, a hash table you watch collide, a net you can draw a digit on. I was pouring my hours into the substrate nobody would ever see and calling it progress because the diffs were big.

The clarity arrived as one sentence I should have heard a month earlier: if you've rebuilt your tool, you didn't need to finish rebuilding it — you needed to notice you picked the wrong one. Every piece I'd hand-rolled had a maintained version of itself sitting one `npm install` away. [Remotion](https://www.remotion.dev/) is a frame-pure component that takes props — the boundary I'd been faking with my custom player. roughjs on a canvas draws its fill straight into a 2D context, contained, with no DOM to thrash and no clip paths to scaffold — the entire rendering circus, gone. And the markers, the small feature that pulled me into the detour? Once a frame is a pure function of its number, "step 7" is just arithmetic over an array of step-start frames. I never needed an engine that *had* steps. I needed an engine that made frames addressable, and then steps are free. That engine already existed.

And here's the part that still stings a little: I wasn't even buying my way out of building it. Remotion's license is free at the size I'm at — it covers individuals and small teams, and only asks larger companies to pay. It was sitting right there, free for me and maintained by someone else, doing every last thing on my list, while I spent December hand-rolling a slower copy and feeling productive about it.

## What the wreck was worth

I don't regret it, which surprised me to type. Building the engine is how I *learned* the architecture I now use in [the debugger post](https://vizlearn.app/blog/debugger-not-an-animator): that the animation has to be a pure function of a frame number, that a step is just a marker over that function, that you snap the facts and glide the positions. I didn't read those rules and adopt them. I backed into them by building a version that got them slightly wrong and feeling exactly where it hurt. So when I finally dropped onto Remotion, I wasn't figuring out what I needed from it — I already knew, in precise detail, because I'd spent a month being its worse competitor. I'd written 679 lines to discover its table of contents.

That was the shape I kept seeing after enough of these detours. A layer down, I'd reached for the Excalidraw *editor* when I needed a *primitive*. One altitude up, I'd reached for a video *studio* when I needed a *component*. And in the middle, for one stubborn month, I reached for *build it yourself* when there was a maintained tool already doing the job. Three flavors of one mistake: matching the tool to the output I could already picture — hand-drawn frames — instead of to the boundary I actually had to build at. The difference this time is that I built the wrong thing all the way to working, ran it, and got to admire it before I understood why it had to go. Some lessons only land at 679 lines.
