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.
The core model
Section titled “The core model”- One container section,
scripts. A script is a recursive{ loop, speed, children }. There is no containermode— each child declares its ownflow(serial/parallel/detached). - Two launch modes, one thing.
start/runbegin a script,stopends it; each script emits<name>.done.start= background use,run= awaited-via-.doneuse. - 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. whenguards are allowed (optional) on handlers for sub-conditions beyond the state.- The verb set is fixed (see Actions):
startstoprunsetrampfadeplaywaitemit, plustofor transitions.
Top level
Section titled “Top level”{ "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.
devices — @id
Section titled “devices — @id”| 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.
palette / defs / lists
Section titled “palette / defs / lists”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]" |
lists — ordered, indexable
Section titled “lists — ordered, indexable”Elements are numbers or #RRGGBB colors. Index them in any expression:
name[i]— elementi, 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 (tclamped 0..1). Use this, notlerp, for colors (lerpblends 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))" }inputs → events
Section titled “inputs → events”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>.pressedstarts,<id>.releasedstops.
fields
Section titled “fields”| 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).
scripts
Section titled “scripts”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 ] }}polyphony (overlap / steal)
Section titled “polyphony (overlap / steal)”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" } ]}parameters (params / args)
Section titled “parameters (params / args)”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] }effect verbs (any child / step)
Section titled “effect verbs (any child / step)”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 |
screen verbs (target a @screen device)
Section titled “screen verbs (target a @screen device)”| 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.
modifiers (on any child / step)
Section titled “modifiers (on any child / step)”| 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 3sstates
Section titled “states”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’senter. - after — fires once,
secseconds 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" }.doruns the actions;totransitions afterward (optional);whenguards the handler (optional — only fires if the expression is truthy).
- string → a pure transition:
events
Section titled “events”- 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
emitaction. Handle it in a state’sonexactly like an input event.
custom events (emit)
Section titled “custom events (emit)”{ "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 (global reactive)
Section titled “rules (global reactive)”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" } ] } ]Actions (the fixed verb set)
Section titled “Actions (the fixed verb set)”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.
References
Section titled “References”@device— a device or segment id (@powerCell).<var>— a variable, written bare (targetSpeed).$varstill 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 inon.ason aplay/effect — names that instance so later actions can target it.@effect.propin an expression — read another effect’s livespeed/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 |
Expressions
Section titled “Expressions”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)orrgb(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.
functions
Section titled “functions”| 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.
built-in variables
Section titled “built-in variables”| 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
Section titled “Validation”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.