Skip to content

show.json format

This is the lookup page for the show format. New here? Start with the first show tutorial. For a one-page summary, see the cheat sheet.

Show files are JSONC: // and /* */ comments are allowed. schemaVersion: 1 and states are the only required sections. A show reads as a few plain ideas:

Section Role Reads as
devices the hardware outputs what’s wired
vars live variables live values
inputs buttons / switches / sensors what the user can do
scripts the containers (looks, cutscenes) what it does
states the state machine when X → do Y / go to Z

There are no scenes, sequences, effects, or layers sections. Everything is a script. The format is a friendly surface over the engine’s primitives (effect tree, animators, audio, control); a small statechart interpreter runs the states.

  1. One container section, scripts. A script is a recursive { loop, speed, children }. There is no container mode — each child declares its own flow (serial / parallel / detached).
  2. Two launch modes, one thing. start/run begin a script, stop ends it; each script emits <name>.done. start = background use, run = awaited-via-.done use.
  3. Transition shorthand. A handler that is a string is a pure transition to that state; an object { "do": [...], "to": "State", "when": "expr" } runs actions and optionally transitions.
  4. when guards are allowed (optional) on handlers for sub-conditions beyond the state.
  5. The verb set is fixed (see Actions): start stop run set ramp fade play wait emit, plus to for transitions.
{
"schemaVersion": 1, // required, = 1
"name": "Proton Pack",
"board": "esp32-s3-devkitc-1", // validates pins
"devices": { /* ... */ },
"vars": { /* ... */ },
"palette": { /* named colors */ },
"defs": { /* named equations */ },
"lists": { /* ordered arrays */ },
"inputs": { /* ... */ },
"scripts": { /* ... */ },
"states": { /* ... */ }, // required
"rules": [ /* global reactive rules */ ]
}

board is optional — the running firmware validates against its own board when omitted. Declare it when authoring for a different target (chiefly the desktop/web playground) so pins are checked against the real hardware. See the board catalog.

field units / values notes
kind ledstrip | digital | pwm | screen omit for a segment
count integer ≥ 1 channels / pixels (default 1)
pin GPIO number validated against board; omit for a screen or a segment
order e.g. GRB, RGBW LED color order
brightness 0..255 LED master brightness (default 120)
width + height pixels screen framebuffer size (0/omitted = backend default; no pin)
segmentOf + start + count parent id, index, length names a slice of a strip
"devices": {
"strip": { "kind": "ledstrip", "count": 67, "pin": 45, "order": "GRB" },
"powerCell": { "segmentOf": "strip", "start": 0, "count": 15 }, // a named slice
"cyclotron": { "segmentOf": "strip", "start": 15, "count": 40 },
"smoke": { "kind": "digital", "pin": 6 },
"panel": { "kind": "screen", "width": 172, "height": 320 } // framebuffer, no pin
}

A device is a segment the moment it has a segmentOf. A segment is a named view into its parent strip: it inherits the parent’s kind, pin, and color order, so it takes no kind/pin of its own.

name → initial number. Live floats that effects/expressions read and actions write.

"vars": { "targetSpeed": 0, "heat": 0 }

A state machine handles modes/conditions — there is no separate mode variable.

Three ways to name reusable values, all usable anywhere an expression is:

section is example reference
palette named color constants "palette": { "fire": "#FF6600" } "color": "fire"
defs named live equations (re-evaluated on read) "defs": { "warm": "hsv(heat*0.1,1,1)" } "color": "warm"
lists ordered collections (palettes, step tables) "lists": { "hues": ["#F00","#0F0","#00F"] } "color": "hues[step]"

Elements are numbers or #RRGGBB colors. Index them in any expression:

  • name[i] — element i, cyclic (wraps modulo length, negative-safe) — so stepping and palette-cycling never overflow.
  • name.len — the element count.

Lists are read-only in v1. The “step through / swap palette” pattern is a scalar index var into a constant list:

"vars": { "step": 0 },
"lists": { "modes": ["#001133", "#003311", "#330011"] },
// advance the index from a state handler (cyclic index means no bounds math):
{ "set": "step", "to": "step + 1" },
// use it — wraps automatically:
{ "fill": "@panel", "color": "modes[step]" }

For palettes and gradients, beyond the math/hsv/rgb builtins:

  • mix(colorA, colorB, t) — per-channel blend of two packed colors (t clamped 0..1). Use this, not lerp, for colors (lerp blends the packed integer and yields garbage).
  • frac(x) — fractional part (handy for gradient positions).
  • pick(a, b, …) — a random one of the given values. For a named list: hues[floor(random()*hues.len)].

A palette gradient sampled across a strip:

{ "pattern": "@strip",
"color": "mix(hues[floor(nx*hues.len)], hues[floor(nx*hues.len)+1], frac(nx*hues.len))" }

An input is a signal. type is digital (default) or analog. It describes the hardware, not the behavior; the state machine decides what a press means (tap vs hold).

A digital input always emits the same four events; its level (0/1) is readable as a var under its id:

event fires when
<id>.pressed went active (edge)
<id>.released went inactive (edge)
<id>.tapped released before holdMs — a short press
<id>.held still active at holdMs — a long press begins (fires once)

An analog input emits <id>.high / <id>.low on threshold crossings and publishes its normalized 0..1 value as a var under its id.

The same button does different things purely by which events your states handle:

  • tap to toggle — two states, each handling <id>.tapped (one starts, the other stops).
  • hold to run<id>.pressed starts, <id>.released stops.
field applies to meaning
label all button label in the dashboard
color all button accent color, e.g. #39ff14
pin all GPIO to read. Omit for a virtual (web/console-driven) input.
activeLow digital true = active when the pin reads LOW (button-to-GND). Default false.
pull digital up / down / omit — internal pull resistor.
holdMs digital press shorter than this fires .tapped, longer fires .held. Default 400.
min / max analog raw ADC range mapped to normalized 0..1 (default 0..4095).
high / low analog normalized 0..1 thresholds; crossing up fires .high, down fires .low.
"inputs": {
"power": { "label": "ON / OFF", "color": "#39ff14" }, // virtual (no pin)
"fire": { "label": "FIRE", "color": "#ff5a2c", "pin": 7, "activeLow": true, "pull": "up" },
"knob": { "type": "analog", "pin": 4 }, // usable as a var: hsv(knob,1,1)
"tilt": { "type": "analog", "pin": 5, "high": 0.7, "low": 0.2 }
}

An analog input’s normalized value is also published as a variable under its id, so you can use knob directly in any color/speed expression. Desktop has no GPIO, so bound inputs there remain web-driven (the binding is still validated against the board).

The one container section. A script is a recursive container: { loop, speed, children }. There is no container mode — each child sets its own flow. You start/run (begin) and stop (end) scripts from states; each emits <name>.done.

field value meaning
loop true | integer N restart forever, or N times. Looping children auto-detach.
speed number / expr / { chase, rate } playback speed; chase glides toward a var. Cascades to children (incl. audio pitch).
children array of steps/scripts the contents (recursive)
autostart bool top-level script: begin at load instead of waiting for start/run.
overlap integer N (default 1) polyphony: max simultaneous copies (“voices”) that may overlap when launched. 1 = monophonic (a relaunch restarts the single instance).
steal first | last | none with overlap > 1, what a launch does when all voices are busy: first (default) restart the oldest, last restart the newest, none drop the launch.
params array of names per-instance parameter names; a run/start passes positional args. Polyphony-safe (each voice keeps its own).

flow — how a child relates to its siblings

Section titled “flow — how a child relates to its siblings”

Each child declares its relation to its siblings via flow (default serial):

flow gates next sibling? parent awaits 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 on a child forces detached (a forever loop can’t be awaited without hanging the parent). A “looping look” is a container whose children loop (and so auto-detach and run together); a “join” marks children parallel.

"scripts": {
"background": { // a looping look, start/stop-ed
"loop": true,
"speed": { "chase": "targetSpeed", "rate": 0.33 },
"children": [
{ "play": "DCBG.wav", "volume": 0.5, "loop": true, "flow": "parallel" },
{ "bar": "@powerCell", "color": "hsv(0.5 - heat*0.5, 1, 1)", "sec": 1, "loop": true, "flow": "parallel" },
{ "comet": "@cyclotron", "color": "#FF0000", "lapSec": 1, "tail": 8, "loop": true, "flow": "parallel" }
]
},
"powerUp": { // a one-shot cutscene, run + .done
"children": [
{ "ramp": "targetSpeed", "to": 1, "rate": 7, "flow": "detached" },
{ "play": "Contact.wav", "volume": 0.5, "flow": "detached" },
{ "wait": 5 } // serial (default): gates & is awaited
]
}
}

By default a script is one instance: run-ning it while it’s already playing restarts it. Set overlap: N to let up to N copies ring out at once — e.g. a 1-second fire.wav fired 3×/sec wants overlap: 3 so the shots don’t clip each other. Each launch grabs a free voice; when all N are busy, steal decides (default: restart the oldest, whose tail is nearly gone). overlap is a property of the script, not the call site: declare it once; every run of it honors it. <name>.done and @name.prop reference voice 0 (the primary instance).

Stopping voices. { "stop": "fireshot" } ends all voices (a “cease fire”); add "voice": <i> to stop just one 0-based voice, e.g. { "stop": "fireshot", "voice": 0 }. An out-of-range index is a no-op. For a non-pooled script (overlap: 1) stop ends the single instance as before.

"fireshot": { // three shots can overlap; a 4th steals the oldest voice
"overlap": 3,
"children": [
{ "play": "fire.wav", "flow": "detached" },
{ "set": "@muzzle", "color": "#FF6000", "sec": 0.05 },
{ "set": "@muzzle", "color": "#000000" }
]
}

A script can declare params and be launched with args, so one script serves many call sites instead of duplicating it. Args are positional (numbers, expressions, or #colors), and each polyphony voice keeps its own — a param reads like any var inside the script.

"scripts": {
"flash": { "params": ["c", "dur"], "children": [
{ "set": "@muzzle", "color": "c", "sec": "dur" },
{ "set": "@muzzle", "color": "#000000" }
] }
},
// call it from a state/handler/child:
{ "run": "flash", "args": ["#FF6000", 0.05] }

The object’s primary key is the effect verb; remaining keys are its parameters:

verb value params
bar @device color, sec
comet @device color, lapSec, tail
spark @device color, sec
chase @device color, off, sec
blink @device color, off, sec, duty
pattern @device color (per-pixel expression, 1-D; reads i, count)
play file.wav volume, as
verb value params
paint @screen color (per-pixel expr; reads x,y,w,h,nx,ny)
fill @screen color
rect @screen color, x, y, w, h, outline
circle @screen color, x, y, r, outline
line @screen color, x0, y0, x1, y1
pixel @screen color, x, y
polygon @screen color, points ([[x,y],…]), outline
text @screen value (static string), color, x, y, size, font
number @screen value (live var/expr), color, x, y, size, digits, font
image @screen file (.bmp/.rgb565), x, y

Colors/numbers may be literals or expressions.

key value effect
loop true | N restart forever / N times (forces detached)
speed number / expr speed multiplier for this step (cascades to its subtree)
intensity 0..1 expr intensity multiplier for this layer
for seconds lifetime — sets the effect’s duration
every seconds cadence — run, wait N sec, repeat forever
after seconds delay N sec before it starts
while expr gate — run only while truthy (freeze otherwise)
modifiers object fluent bundle of the same lifecycle overrides
{ "every": 3, "spark": "@strip", "color": "#fff", "sec": 0.1 } // a flicker every 3s

The machine. initial names the starting state; each state has optional enter/exit action lists, an optional after timer, and an on map of event → handler.

"states": {
"initial": "Standby",
"Standby": { "enter": [ { "set": "@strip", "color": "#000000" } ],
"on": { "power.pressed": "PoweringOn" } },
"PoweringOn": { "enter": [ { "start": "background" }, { "run": "powerUp" } ],
"on": { "powerUp.done": "Idling", "power.released": "PoweringOff" } },
"Firing": {
"enter": [ { "start": "fire" }, { "ramp": "heat", "to": 1, "rate": 0.1 } ],
"after": { "sec": 10, "to": "Overheated" }, // fire once, N sec after entry
"on": {
"fire.released": { "do": [ { "stop": "fire" } ], "to": "Idling" },
"power.released": "PoweringOff"
}
}
}
  • enter / exit — action lists run on entering/leaving the state. On a transition: old state’s exit, then new state’s enter.
  • after — fires once, sec seconds after entry; takes the same handler shape (do/to/when).
  • on — maps an event to a handler:
    • string → a pure transition: "powerUp.done": "Idling".
    • object{ "do": [actions], "to": "State", "when": "expr" }. do runs the actions; to transitions afterward (optional); when guards the handler (optional — only fires if the expression is truthy).
  • input events: <input>.pressed|released|tapped|held (digital) or <input>.high|low (analog).
  • script completion: <script>.done (a looping script never completes until stopped).
  • custom events: any name raised by an emit action. Handle it in a state’s on exactly like an input event.

{ "emit": "<name>" } raises a named event that any state’s on (or a rule/after) can handle — letting a script, timer, or threshold signal the state machine instead of only inputs. Events are dispatched run-to-completion: an emit fired inside a handler (or an enter/exit/rule) is queued and delivered right after the current handler finishes, against whatever state is current then. A chain of emits resolves within the same step (bounded, so an A→emit B→emit A cycle can’t spin forever). An event no current state handles is ignored.

"Firing": {
"enter": [ { "ramp": "heat", "to": 1, "rate": 0.1 } ],
"on": { "overheated": "Cooldown", "fire.released": "Idling" }
},
// a global rule turns a threshold into an event, handled above:
"rules": [ { "when": "heat >= 1", "do": [ { "emit": "overheated" } ] } ]

rules fire their do on the rising edge of when, in any state — a threshold or timer that signals the machine regardless of the current state.

"rules": [ { "when": "heat >= 1", "do": [ { "emit": "overheated" } ] } ]

Used in enter/exit, script children, handler do lists, and rules:

action form effect
start { "start": "<script>" } begin a script (background use)
stop { "stop": "<script>" } end a script (add "voice": <i> to stop one voice)
run { "run": "<script>" } begin a script, listen for <script>.done (add "args": [...] for params)
set (var) { "set": "<var>", "to": <num/expr> } assign a variable
set (device) { "set": "@dev", "color": "#.." } or { "set": "@dev", "on": true } drive a device output
ramp { "ramp": "<var>", "to": <n>, "rate": <n> } linear-rate animate a var toward to
fade { "fade": "<script|var>", "to": <n>, "rate": <n> } fade a script’s intensity (or a var)
play { "play": "file.wav", "volume": <n>, "as": "<id>" } one-shot sound
wait { "wait": <sec> } pause before the next (serial) sibling
emit { "emit": "<event>" } raise a custom event

A device set normally lasts one frame; add "sec": <n> to hold it (re-asserting every frame so it overrides a script painting the same device underneath). Transitions are expressed with to on a handler — not an action.

  • @device — a device or segment id (@powerCell).
  • <var> — a variable, written bare (targetSpeed). $var still resolves but the $ is legacy/optional (validation notes it); prefer bare everywhere.
  • script / state — by bare name (background, powerUp, Idling).
  • <input>.<event> — an event (fire.pressed, vent.tapped).
  • <script>.done — fires when a script finishes; listen for it in on.
  • as on a play/effect — names that instance so later actions can target it.
  • @effect.prop in an expression — read another effect’s live speed/intensity/etc.
field(s) unit
sec, lapSec, wait, after.sec, for, every, after seconds
rate units per second
duty, intensity, volume, analog high/low 0..1
speed multiplier
brightness 0..255
color #RRGGBB or a color expression
screen x/y/w/h/r/points pixels

Anywhere a color or number appears you can write a literal or a quoted expression string. The expression compiles once and evaluates every frame, so it tracks vars and time live.

  • Color literal"#RRGGBB" (e.g. "#39ff14").
  • Color expression — anything returning a packed color, usually hsv(h,s,v) or rgb(r,g,b).
  • Number expression"clamp(speed, 0, 2)", "sin(time)*0.5 + 0.5", etc.

Operators (by precedence, low → high): a ? b : c · || · && · == != < > <= >= · + - · * / % · prefix - + !. Values are float-typed; nonzero = true. Number bases: decimal, scientific (1e3), and hex (0xFF). Constants: true, false, pi.

group functions
math abs floor ceil round sqrt sin cos tan sign pow(x,y) mod(x,y) frac(x)
range & selection min max (variadic) · clamp(x,lo,hi) · lerp(a,b,t) (numbers only) · map(x,inLo,inHi,outLo,outHi) · step(edge,x) · smoothstep(e0,e1,x) · if(cond,a,b) · pick(a,b,…) · random() / random(lo,hi)
color rgb(r,g,b) (args 0..255) · hsv(h,s,v) (args 0..1, h wraps) · mix(colorA,colorB,t) (per-channel blend, t clamped 0..1)

Use mix() to blend colors, not lerp()lerp blends the packed integer and produces garbage.

name scope value
time everywhere seconds since start (float), updated each frame
pi everywhere 3.14159…
i / count pattern (1-D) pixel index / pixel total (i/count = normalized position)
x y / w h / nx ny paint (2-D) pixel coordinates / canvas size / normalized 0..1
<input> everywhere an input’s live level (0/1 digital, 0..1 analog)

Plus everything you name in vars, palette, defs, and lists, and any @effect.prop (a live effect property like @leds.speed).

Validation writes a full report to show.log. Checks: initial names a real state; every to/transition targets a defined state; start/stop/run reference a defined script; set/ramp/fade targets resolve; events reference defined inputs (or a <script>.done).

Errors reject the show — the last good show keeps running. Warnings proceed — the show loads anyway.