How rendering works
This page is a mental model of what happens between your config.toml and the panel lighting up — useful whether you’re tuning a config or building a plugin. The other concept pages go deep on each piece; this one shows how they fit together.

The pipeline
Section titled “The pipeline”Config (TOML) ↓ parsed at startupPlaylist → sections → widgets ↓ engine tick (~20 fps)widget.draw() → logical canvas ↓ ScaledCanvas expands it (when scale > 1)overlay hooks paint (e.g. the busy light) ↓LedFrame.swap() → double-buffered → panelEach section below is one step of that flow.
The engine loop
Section titled “The engine loop”At startup, led-ticker parses your config into a playlist of sections, each holding one or more widgets. Then a background engine runs a steady loop at 20 frames per second — one tick every 50 ms.
On each tick the engine:
- advances the frame counter(s) — every animated effect (rainbow text, a color cycle, a typewriter) has its own counter that moves one step;
- redraws the current widget onto the canvas at its new frame;
- pushes the finished frame to the panel (below), then sleeps until the next tick.
A section’s mode decides how its widgets are shown — held in place (slideshow) or scrolling — and for how long; transitions play the animation between one widget or section and the next. (An effect whose output doesn’t change with the frame skips the per-tick redraw — see frame counters.)
The canvas
Section titled “The canvas”Widgets don’t draw to physical LEDs directly. They draw to a logical canvas — a fixed 16-pixel-tall grid — using simple (x, y) coordinates, so a widget never needs to know how big your sign is. When your [display] runs at scale > 1 (a big sign), a ScaledCanvas wraps the real panel and expands every logical pixel into a scale × scale block, centering the content vertically.
That’s the short version — Display covers scaling, the content_height × scale ceiling, and per-section overrides in full.
Reaching the panel
Section titled “Reaching the panel”When a frame is ready, the engine calls LedFrame.swap() — the buffer swap, unrelated to the slideshow section mode above — which does two things in order:
- runs every registered overlay hook — paint functions that draw over whatever’s on screen, every frame, like the busy light’s status dot;
- performs a double-buffered swap: the new frame is sent to the panel while the next one is drawn off-screen, so the display never tears or flickers mid-update.
Then the loop sleeps and the next tick begins.
Why it’s built this way
Section titled “Why it’s built this way”A few deliberate choices explain the rest:
- A fixed tick. Driving everything from one steady ~20 fps clock keeps animations smooth and in sync, and makes timing predictable across very different signs.
- A write-only panel. The hardware framebuffer can be written but not read back, so widgets and effects recompute each frame from their frame counter rather than reading the current pixels — that’s why effects are functions of the frame number.
- Logical, then physical. Keeping drawing in logical 16-tall coordinates and expanding to the real panel at swap time means one widget runs unchanged on a tiny sign or a giant one.
The full engineering rules behind this — the hardware-rendering constraints that keep the panel from freezing — live in docs/plugin-system.md and the project’s CLAUDE.md, for contributors and plugin authors who need them.