I was writing my own engine to replace a paid DJ lighting product. The behaviour I was matching: pause the track and the show pauses, rewind and the show rewinds, jump into the middle and the show jumps with you.
To a user that is “synchronised”. To the code it means something far stricter.
The natural approach is the wrong one
The most natural way to describe a lighting effect is step by step:
// a chase effect
every frame:
active = (active + 1) % lightCount
turn all off, light the active one
It works. It looks good. Right up until the user grabs the timeline and jumps to second 90.
At that moment you have asked the engine: “which light should be on at second 90?” And it cannot know, because that number is the accumulated result of every frame before it. Computing it means replaying every frame from 0 to 90 — four thousand steps, on every seek.
One rule
For a given timecode t, return every channel’s value. The only input is t. The engine remembers nothing, accumulates nothing, and never needs t−1 to have been computed.
The same chase under that constraint:
active = floor(t × speed) % lightCount
A one-line difference. But now asking for second 90 costs exactly what asking for second 0 costs.
Every oscillation takes its phase from time
| effect | stateful (wrong) | pure (right) |
|---|---|---|
| chase | i = i + 1 | i = ⌊t·s⌋ mod n |
| strobe | if(count%4==0) toggle | on = (t·f) mod 1 < 0.5 |
| fade | v += step | v = (t−t₀)/duration |
| movement | angle += speed | angle = sin(2π·f·t) |
What every expression on the right shares: it looks at nothing but t.
The test for any new effect is: can it be evaluated at an arbitrary t without having evaluated anything before it? If not, the effect gets rewritten.
What comes for free
Setting this rule at the start cost nothing. What it returned:
- Seek and scrub — the original goal, solved by construction.
- Pause — simply stop advancing
t. No separate logic. - Slow and fast playback — advance
tat a different rate; effects follow automatically. - Preview — show what happens at any
twithout playing anything. - Testability — testing an effect needs no 44-frame simulation; call
render(37.5)and inspect. - Frame dropping — if the machine stalls, dropping frames is harmless; the next frame resumes in the right place.
The last one matters on a stage. In a stateful engine, one dropped frame leaves the show permanently behind the music and it never realigns.
The show itself is another layer
The same separation exists one level up. The show generator does not think about DMX channels. It produces abstract intent:
t=42.0 "warm amber wash, 60%"
t=45.5 "accent hit on the downbeat"
t=48.0 "haze four bars before the drop"
A separate render layer turns that intent into output. So the same choreography can drive DMX fixtures or the ZigBee bulbs at home.
Analysis is a separate layer too: analysing a track is slow (about half a minute), generating the show is fast (milliseconds). You will try the choreography dozens of times; re-analysing the track each time is pointless. The file that sits between them both enforces that separation and is hand-editable — if the algorithm misses a drop, you can intervene.
What I learned
Some constraints produce freedom. “Hold no state” looks like it makes effects harder to write; in reality it hands you six features for nothing.
And constraints like this have to be set at the beginning. Adding one later is not “a bit of refactoring”; it is rewriting the entire effect engine.