Skip to content

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.

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.

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.

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).

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.pressed starts it, go.released stops 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.

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 lookloop: 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
] }

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>.done to know it finished. Awaited use: cutscenes.

A looping script never completes until stopped.

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.

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" }do runs actions, to transitions afterward (optional), when guards 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).

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.