Jul 8, 2026 / 6 min read
Building Broadsheet, a Rust Animation Engine for Systems Explainers
How I built a deterministic Rust animation engine with stateless timelines, camera-aware rendering, newspaper styling, and ffmpeg export.
I built a small animation engine in Rust called broadsheet.
The goal is specific: make algorithm and data-structure explainers feel designed without hand-editing every frame. I wanted something more programmable than slide software, but more opinionated than a blank canvas.
The result is a code-driven 2D animation engine with a newspaper-inspired visual system: off-white paper, ink strokes, serif mastheads, monospace labels, muted spot colors, and deterministic frame rendering.
The Core Constraint
The most important design rule is:
scene_at_time = f(base_scene, timeline, t)
Every frame is evaluated as a pure function of absolute time t.
That means playback does not depend on previous frames. If the player jumps from t = 2.0 to t = 11.5, the engine does not simulate everything in between. It resolves the timeline directly at 11.5.
That one constraint makes a lot of editor-like behavior simpler:
- pause
- scrub
- frame-step
- jump to section markers
- render frame
nat exactlyt = n / fps - regenerate the same output deterministically
The player and the offline recorder both use the same evaluation path.
Movie Scripts
A movie script starts by declaring the base scene, then placing animation clips on a timeline.
use broadsheet::prelude::*;
fn main() {
let mut m = Movie::new("Skip Lists", 1280, 720);
m.scene()
.circle("A", v(300., 400.), 40.).label("A")
.circle("B", v(900., 400.), 40.).label("B").hidden()
.arrow("e", v(340., 400.), v(340., 400.)).hidden()
.text("cap", v(640., 620.), "").size(22.).color(FADED).hidden();
m.play(act().set_text("cap", "two nodes, one pointer"));
m.play(act().fade_in("B").dur(0.4));
m.play(par![
act().fade_in("e").dur(0.15),
act().grow_to("e", v(860., 400.)).dur(0.5).ease(InOutCubic),
]);
broadsheet::run(m);
}
There are two separate layers here:
- The scene is an id-addressed store of entities.
- The timeline is a set of tracks and text events placed at absolute times.
The IDs are deliberately plain strings. That makes movie scripts quick to write:
act().highlight("code.line2", ACCENT)
act().fade_out("node42")
act().retarget("edge", v(900.0, 300.0))
Clips, Tracks, And Resolution
Animation verbs produce unresolved track specs.
For example:
act().move_by("A", v(100.0, 0.0))
does not know the final absolute position until the engine resolves all prior tracks for A.pos.
At finalization time, the engine walks tracks per (entity, property) and turns relative, absolute, and revert targets into concrete keyframes:
TrackSpec
id: "A"
prop: Pos
target: Rel(Vec2(100, 0))
start: 2.0
dur: 0.5
resolved into:
Track
from: Vec2(...)
to: Vec2(...)
After that, playback is just interpolation.
That structure also makes compound gestures easy. A pulse is two scale tracks. A highlight is a color track followed by a revert track. A text swap is a fade-out, a text event, and a fade-in.
Composition Primitives
The animation DSL has three core composition tools:
seq![a, b, c] // play one after another
par![a, b, c] // play together
stagger(delay, clips)
The features_demo example uses these for cascaded cells, curved arrow reveals, camera moves, and group fade-outs.
Useful verbs today include:
move_tomove_byfade_infade_outcolor_tohighlightscale_topulseshakegrow_toretargettrace_intrace_outtype_incam_tocam_zoom
trace_in is used for draw-on effects. On strokes and outlines, it reveals path length. On text, the same trace value becomes a typewriter reveal.
Primitives For Explainers
The engine has a small set of primitives that cover many systems animations:
- circles and labels for nodes
- rectangles for boxes and memory regions
- lines and arrows for pointers
- curved arrows for dense graphs and rings
- polygons for regions
- text and wrapped captions
- code blocks with addressable per-line entities
- cells for arrays, bitsets, hash tables, and ring buffers
There are also layout helpers for rows, grids, trees, and rings.
The point is not to be a complete scene graph. The point is to reduce the repetitive work in explainers. I do not want to hand-place every bit in a Bloom filter or every node in a consistent-hash ring.
Camera-Aware Page Rendering
The initial version treated the newspaper masthead as a fixed overlay. That looked nice until the camera zoomed into the page: the content moved while the title stayed glued to the screen.
That was the wrong default.
In the current version, the page chrome belongs to the world. Camera moves treat the masthead, border, and scene content as part of the same page.
For UI-like overlays, entities can opt into screen-space behavior:
m.scene()
.text("hud", v(20.0, 20.0), "debug")
.left()
.sticky();
So the default is camera-aware world rendering, with .sticky() as the explicit escape hatch.
Recording Pipeline
The renderer supports live preview and deterministic offline export.
cargo run --example features_demo -- --record renders --fps 30 --scale 1.5 --grain
The recorder renders at a fixed timestep:
t = frame / fps
It can pipe raw RGBA frames directly into ffmpeg, write PNG sequences, export still frames, render time ranges, emit GIFs, and write marker JSON for lining up narration.
Useful flags include:
--record DIR--fps N--scale F--from S --to S--still S--alpha--png--gif--grain
For a small engine, the export path matters a lot. If it is hard to get frames into an editor, the animation system becomes a toy. The goal is to make the rendered artifact easy to use immediately.
Why Rust?
Rust is not required for a 2D animation engine. A lot of great animation tooling lives in JavaScript, Python, C++, and shader-heavy stacks.
For this project, Rust is useful because the engine is really a set of data transformations:
SceneBuilder -> Scene
Clip specs -> resolved Timeline
Timeline + t -> Scene snapshot
Scene snapshot -> draw calls
draw calls -> recorded frames
The types help keep those boundaries explicit. The ownership model also nudges the engine toward immutable base data plus derived per-frame state, which fits the stateless timeline design.
What Broadsheet Is Not
It is not a GUI editor.
It is not trying to become After Effects.
It is not a general-purpose game engine.
It is a code-first animation toolkit for a specific kind of technical video: the kind where pointers move, arrays change color, code lines highlight, graphs rewire, and the explanation benefits from precise, repeatable timing.
That narrowness is useful.
Future Work
The next direction is making the engine more extensible without losing its opinionated feel.
The areas I want to explore:
- theme packs beyond the newspaper visual system
- reusable scene components for common data structures
- custom animation verbs without editing engine internals
- richer graph, tree, table, and memory-layout primitives
- more layout algorithms
- better section/page transitions
- golden-frame tests for renderer determinism
- more export profiles for social clips and article embeds
The long-term shape I want is:
stable deterministic core
small expressive animation DSL
pluggable visual themes
reusable explainer components
That would make it possible to keep the same animation logic while changing the visual identity.
Links
- Crate:
broadsheet - Docs:
docs.rs/broadsheet - Repository:
SK1PPR/broadsheet