How it works
An RFX device runs a show — a single JSON file you can upload over the air. The firmware never changes; the JSON is your program. This page gives you the mental model behind every show. Once these five ideas click, the rest is vocabulary.
A show answers five questions
Section titled “A show answers five questions”| Section | Question it answers | Think of it as |
|---|---|---|
devices |
What hardware is wired up? | the nouns |
vars |
What live values do I track? | the memory |
inputs |
What can the user do? | the buttons |
scripts |
What looks/cutscenes can play? | the verbs |
states |
When X happens, do Y and go to Z | the brain |
Everything is declarative. You describe what things are and when things happen; the engine handles the frame-by-frame animation, timing, audio mixing, and looping.
Every show is one JSON object with this shape (only schemaVersion and states
are strictly required):
{ "schemaVersion": 1, "name": "My Show", "board": "esp32-s3-devkitc-1", // used to validate your pins
"devices": { }, "vars": { }, "inputs": { }, "scripts": { }, "states": { }}Show files are JSONC — you can add // comments to explain your show. If
validation finds any error, the whole show is rejected and the device keeps
running the last good one; warnings let it load anyway. Everything is written
to show.log, and the dashboard shows a pass/fail banner when you save.
Devices — the nouns
Section titled “Devices — the nouns”A device declares one piece of output hardware: a kind (ledstrip, digital,
pwm, or screen), a count of channels, and a pin. Everywhere else in the
show you refer to it by name as @strip — effects never touch pins directly.
"devices": { "strip": { "kind": "ledstrip", "count": 16, "pin": 45, "order": "GRB" }}Segments name a slice of a strip as its own device, so one physical strip can act as several logical ones:
"devices": { "strip": { "kind": "ledstrip", "count": 67, "pin": 45, "order": "GRB" }, "powerCell": { "segmentOf": "strip", "start": 0, "count": 15 }, "cyclotron": { "segmentOf": "strip", "start": 15, "count": 40 }}A screen device is a framebuffer panel — it has width/height instead of
a pin, and you draw on it with screen verbs like fill, rect, text,
number, and paint.
Vars — the memory
Section titled “Vars — the memory”A var is a named number with a starting value:
"vars": { "speed": 1 }Effects read vars (for colors, speeds), and actions write them (set,
ramp, fade). Think of vars as the dials your state machine turns: rather than
hard-coding a color or speed, point it at a var and animate the var — that’s how
transitions stay smooth. There’s one built-in var, time, which counts seconds
(great for hsv(time*0.1, 1, 1) rainbows).
Inputs — the buttons
Section titled “Inputs — the buttons”An input is digital (the default) or analog. That’s the hardware; what a
press means is decided by your states, not the input. A digital input always
emits all four events:
| event | fires when |
|---|---|
<id>.pressed |
went active (edge) |
<id>.released |
went inactive (edge) |
<id>.tapped |
released quickly — a short press (< holdMs, default 400) |
<id>.held |
held past holdMs — a long press begins |
"inputs": { "go": { "label": "GO", "color": "#39ff14", "pin": 7, "activeLow": true, "pull": "up" }}So the same button covers both classic patterns — you just pick the events:
- Tap to toggle: two states, each handling
go.tapped— one starts, the other stops. - Hold to run:
go.pressedstarts it,go.releasedstops it.
Every input also publishes its live level as a var under its own id (0/1
digital, 0..1 analog), so an analog knob can drive an expression like
hsv(knob, 1, 1) directly.
Scripts — the one container
Section titled “Scripts — the one container”Anything that “does something over time” — a background look, a cutscene, a single flash — is a script. There are no separate scenes, sequences, or effects sections; a script is one recursive container:
{ "loop": ..., "speed": ..., "children": [ ... ] }Each child’s primary key is an effect verb (comet, bar, blink,
spark, play, screen verbs, …) or a nested script. And each child declares
its own flow — how it relates to its siblings:
flow |
gates the next sibling? | parent waits for it? | use it for |
|---|---|---|---|
serial (default) |
yes | yes | a step in a script (one after another) |
parallel |
no | yes | members of a join (run together, all awaited) |
detached |
no | no | fire-and-forget (background sound / loop) |
loop: true on a child forces detached — a forever loop can’t be awaited
without hanging the parent. That gives you the two everyday shapes:
A looping look — loop: true, children that also loop, so they auto-detach
and run together as a background:
"idle": { "loop": true, "speed": { "chase": "speed", "rate": 2.0 }, "children": [ { "comet": "@strip", "color": "hsv(time*0.1, 1, 1)", "lapSec": 2.0, "tail": 6, "loop": true } ]}A one-shot cutscene — serial children that run top to bottom, then the script
finishes and emits <name>.done:
"powerUp": { "children": [ { "play": "Contact.wav", "flow": "detached" }, // overlaps the next step { "ramp": "targetSpeed", "to": 1, "rate": 7 }, // serial: gates what follows { "wait": 5 } ]}Want “A and B together, then C”? A serial group whose members are parallel:
{ "children": [ { "children": [ { "play": "A.wav", "flow": "parallel" }, { "play": "B.wav", "flow": "parallel" } ] }, // both at once, both awaited { "play": "C.wav" } // runs after A & B finish] }Launching scripts
Section titled “Launching scripts”You launch any script from a state in one of two ways — the difference is intent, not type:
start <script>— begin it; it runs alongside;stop <script>ends it. Background use: looks and loops.run <script>— begin it; listen for<script>.doneto know it finished. Awaited use: cutscenes.
A looping script never completes until stopped.
Speed flows downhill
Section titled “Speed flows downhill”The single most important engine rule: time, speed, and intensity flow
downhill. Set a script’s speed and every child inside it — nested scripts,
light effects, and audio pitch — scales with it. A script’s speed can be a
number, an expression, or { "chase": "<var>", "rate": n }, which glides the
speed toward a moving var. Point that at a var, ramp the var from a state, and
your whole prop smoothly spins up or winds down as one living thing. That one
property is what makes shows feel alive instead of scripted.
States — the brain
Section titled “States — the brain”The state machine ties everything together. initial names the starting state;
each state has optional enter/exit action lists and an on map of
event → handler:
"states": { "initial": "Off",
"Off": { "enter": [ { "set": "@strip", "color": "#000000" } ], "on": { "go.tapped": "On" } },
"On": { "enter": [ { "start": "idle" } ], "exit": [ { "stop": "idle" } ], "on": { "boop.tapped": { "do": [ { "run": "blink" } ] }, "go.tapped": "Off" } }}A handler is either:
- a string → a pure transition:
"go.tapped": "Off". - an object →
{ "do": [actions], "to": "State", "when": "expr" }—doruns actions,totransitions afterward (optional),whenguards it (optional).
A button is enabled automatically when the current state handles one of its
events — no separate bookkeeping. If a state has no handler for boop.tapped,
the BOOP button is dark in that state.
A state can also fire on its own after a delay:
"Firing": { "enter": [ { "start": "fire" } ], "after": { "sec": 10, "to": "Overheated" }}The actions available in enter/exit, handler do lists, and script children
are a small fixed set: start, stop, run, set (var or device), ramp,
fade, play, wait, and emit (raise a custom event).
Expressions — live values everywhere
Section titled “Expressions — live values everywhere”Anywhere a color or number appears, you can use a literal ("#39ff14",
2.0) or a live expression in quotes — re-evaluated every frame, tracking
vars and time in real time:
{ "bar": "@powerCell", "color": "hsv(0.5 - heat*0.5, 1, 1)", "sec": 1, "loop": true }The language has the usual operators (+ - * / %, comparisons, && || !,
ternary), and built-ins including abs floor ceil round sqrt sin cos tan sign pow mod min max clamp lerp step smoothstep if map random, plus rgb(r,g,b)
(args 0..255) and hsv(h,s,v) (args 0..1, hue wraps). map(...) and
lerp(...) are the workhorses for remapping a var into a color or speed range.
Where next
Section titled “Where next”- Build one for real: Your first show.
- Terse spec of every field: Show format.
- One-page reminder: Cheatsheet.