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": { /* buttons, switches, virtual buttons */ }, "buses": { /* i2c / spi / uart / audio transports */ }, "drivers": { /* part protocols, as data */ }, "sensors": { /* measured 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 | servo | stepper |
omit for a segment |
dir |
GPIO number | stepper only: the DIR pin (STEP pulses go to pin) |
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 |
segments |
array of { id, count, [start] } |
on a strip: ordered child regions; start auto-accumulates |
"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.
Segments the easy way
Section titled “Segments the easy way”Listing start by hand means counting where the last region ended — and one wrong number silently shifts everything after it. Instead, nest the regions under the strip with just their counts and let the loader do the arithmetic:
"devices": { "strip": { "kind": "ledstrip", "pin": 45, "order": "GRB", "segments": [ { "id": "powerCell", "count": 15 }, // start 0 { "id": "cyclotron", "count": 40 }, // start 15 (auto) { "id": "nfilter", "count": 12 } // start 55 (auto) ] }}Each start is the running sum of the ones before it, and the strip’s own count defaults to the total (67 here) — omit it. This is exactly equivalent to the flat segmentOf form above; reference each region as @powerCell etc. either way. Give a segment an explicit start to leave a gap or overlap, and set the strip’s count yourself if it’s physically longer than its named regions.
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).
Peripherals (buses, drivers, sensors)
Section titled “Peripherals (buses, drivers, sensors)”Three sections that arrived together. A bus is a transport declared once and shared; a driver describes a part’s protocol as data; a sensor is one part, deployed, publishing variables and events.
"buses": { "i2c0": { "type": "i2c", "sda": 8, "scl": 9, "hz": 400000 } },"sensors": { "temp": { "bus": "i2c0", "addr": "0x48", "driver": "tmp102", "hz": 4 } }| field | applies to | notes |
|---|---|---|
type |
all | i2c | spi | uart | i2s | qwiic. gpio is implicit — a bare pin already means it |
sda + scl |
i2c | validated against the board’s pin capabilities. hz defaults to 400000 |
sck + mosi + miso |
spi | mode 0..3 (default 0), hz defaults to 8000000. Omit miso for a write-only part |
tx + rx |
uart | baud defaults to 115200 |
bclk + lrc + din |
i2s | breakout silkscreens differ, so aliases are accepted (sck/clk, ws/lrclk, dout/sd) |
Two names are special. qwiic needs no pins on a board that declares the
socket — { "type": "qwiic" } and a part says "bus": "qwiic". audio is
the one bus named for its role rather than its transport: it is where sound
goes out, config.json spells it exactly the same way, and a unit wired
differently overrides it there without touching the show.
sensors
Section titled “sensors”Keyed by id, like every other section. A sensor publishes, for an entry named
temp:
| name | what |
|---|---|
temp.c |
a channel’s value — usable in any expression or when: guard |
temp |
the channel marked primary, so a one-channel part reads naturally |
temp.ok |
1 while the last read succeeded |
temp.c.above / .below / .changed |
events, hysteresis-paired |
temp.found / temp.lost |
presence changes |
Fields: driver, bus (omit for a pin-based driver), addr (i2c), cs (spi),
pin (adc/gpio), hz (poll rate, default 10 — deliberately decoupled from frame
rate), label, params, channels, and sim — an expression that drives the
primary channel on desktop/WASM, so a show can be built and tested with no part
plugged in.
Per channel you can add a filter chain (median, ema, quantize) and
events: { above, below } thresholds.
Sensors are kept separate from inputs on purpose: a button is a deliberate
act, a thermometer is a measurement, and a show file reads better when the two
look different. Underneath they share the same buses, drivers and pipeline.
drivers
Section titled “drivers”A drivers entry describes a part the firmware has never heard of — no
recompile, no firmware release. interface names the transport it speaks, then
probe, init and read are lists of transactions (write, read, delay,
rmw), and fields slices the bytes those reads returned into named numbers
that channels turns into values with units.
Named catalog drivers (compiled in, e.g. "driver": "tmp102") stay available
and are the right answer for a common part; the declarative tier is what makes
the uncommon one possible at all. Full rules and worked examples live in the
engine’s docs/PERIPHERALS.md.
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 |
polyline |
@screen |
color, points ([[x,y],…]), close — connected open path; one step instead of many lines |
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.