Skip to content

Your first show

In this tutorial you’ll write a complete show.json from scratch: a light strip that toggles a rainbow animation with one button, plus a second button for a “boop” flash. No code, no reflashing — just a JSON file.

You need a working device. If you haven’t set one up yet, follow Quickstart: Standalone or Quickstart: Loader first, then come back. (No hardware? The desktop preview runs the same engine in your browser and works for this whole tutorial.)

You’ll be editing the show.json file on your device — either on its USB drive or in the web dashboard’s Files tab. Each time you save, the dashboard shows a pass/fail validation banner (full report under Console ▸ validation). On the desktop preview, changes hot-reload live; a device re-reads the show on reload/reboot.

Start with the minimum valid show: a schema version and a state machine with one (empty) state.

{
"schemaVersion": 1,
"name": "First Light",
"board": "esp32-s3-devkitc-1", // use YOUR board's id — see its board page
"states": {
"initial": "Off",
"Off": { }
}
}

The board field lets the validator check your pins against real hardware. Show files are JSONC, so // comments are fine.

What you should see: the validation banner passes. Nothing else happens yet — there’s no hardware declared.

Add a devices section above states, and make the Off state prove the wiring by painting the strip on entry:

"devices": {
"strip": { "kind": "ledstrip", "count": 16, "pin": 45, "order": "GRB" }
},
"states": {
"initial": "Off",
"Off": {
"enter": [ { "set": "@strip", "color": "#003311" } ]
}
}

Match count, pin, and order to your actual strip — the pin is validated against your board, so a wrong pin shows up as a warning, not a mystery. Everywhere else in the show, the strip is just @strip; nothing but this one line ever mentions the pin.

What you should see: on reload, the strip glows a dim green. Your hardware declaration works.

Add an inputs section. We’ll make it virtual (no pin), so it appears as a button on the web dashboard — perfect for testing:

"inputs": {
"go": { "label": "GO", "color": "#39ff14" }
}

Have a real button wired up? Add pin details and it becomes physical: { "label": "GO", "color": "#39ff14", "pin": 7, "activeLow": true, "pull": "up" } for a button to ground.

What you should see: a GO button appears on the dashboard — but it’s dark. Buttons only light up in states that handle their events, and no state does yet.

Now the fun part. Add a looping script — a rainbow comet sweeping the strip — and split the state machine into Off and On:

"scripts": {
"idle": {
"loop": true,
"children": [
{ "comet": "@strip", "color": "hsv(time*0.1, 1, 1)", "lapSec": 2.0, "tail": 6, "loop": true }
]
}
},
"states": {
"initial": "Off",
"Off": {
"enter": [ { "set": "@strip", "color": "#000000" } ],
"on": { "go.tapped": "On" }
},
"On": {
"enter": [ { "start": "idle" } ],
"exit": [ { "stop": "idle" } ],
"on": { "go.tapped": "Off" }
}
}

Read it as English: start in Off with the strip blanked. A tap of GO goes to On, which starts the idle script on entry and stops it on exit. Another tap goes back to Off. The button is momentary — the states remember which side you’re on, which is why go.tapped appears in both.

The comet’s color is a live expression: hsv(time*0.1, 1, 1) cycles the hue as the built-in time var counts seconds. Expressions work anywhere a color or number does.

What you should see: GO is now lit in both states. Tap it — a rainbow comet chases around your strip. Tap again — dark.

One more input, and a one-shot script that flashes the whole strip white over the top of the animation:

"inputs": {
"go": { "label": "GO", "color": "#39ff14" },
"boop": { "label": "BOOP", "color": "#2ca7ff" }
},
"scripts": {
"idle": { /* ...unchanged... */ },
"blink": {
"children": [
{ "play": "boop.wav", "flow": "detached" },
{ "set": "@strip", "color": "#ffffff", "sec": 0.15 }
]
}
}

Then give the On state a handler that runs it:

"On": {
"enter": [ { "start": "idle" } ],
"exit": [ { "stop": "idle" } ],
"on": {
"boop.tapped": { "do": [ { "run": "blink" } ] },
"go.tapped": "Off"
}
}

Two details worth noticing:

  • The set has "sec": 0.15 — that holds the white for 0.15 s, re-asserting it every frame so it overrides the comet painting the same strip underneath. Without sec, your flash would last one frame and vanish.
  • The play is flow: "detached" so the sound fires and forgets, overlapping the flash instead of delaying it.

What you should see: in On, the BOOP button is lit; tap it and the strip flashes white (with a boop, if you added the sound), then the rainbow resumes. In Off, BOOP is dark — that state has no handler for it.

{
"schemaVersion": 1,
"name": "First Light",
"board": "esp32-s3-devkitc-1", // use YOUR board's id
"devices": {
"strip": { "kind": "ledstrip", "count": 16, "pin": 45, "order": "GRB" }
},
"inputs": {
"go": { "label": "GO", "color": "#39ff14" },
"boop": { "label": "BOOP", "color": "#2ca7ff" }
},
"scripts": {
"idle": {
"loop": true,
"children": [
{ "comet": "@strip", "color": "hsv(time*0.1, 1, 1)", "lapSec": 2.0, "tail": 6, "loop": true }
]
},
"blink": {
"children": [
{ "play": "boop.wav", "flow": "detached" },
{ "set": "@strip", "color": "#ffffff", "sec": 0.15 }
]
}
},
"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"
}
}
}
}

You’ve now used every core concept: devices, inputs, scripts, flow, and states. From here:

  • Prefer hold-to-run over toggle? Handle go.pressed / go.released instead of go.tapped — the input itself never changes.
  • Add a speed to the idle script, point it at a var, and ramp the var from a state — the whole look (and any audio) glides faster or slower.
  • Every field, verb, and event in one place: Show format.
  • Keep the one-pager beside you while you write: Cheatsheet.