Skip to content

Scene Authoring Guide

Everything you need to write Lightnet scenes by hand and use every feature to its full potential: how scenes work, how the panel network is shaped, every JSON property, how to target panels and give effects direction, and a library of ready-to-use example scenes.

This guide is the authoring home. Two reference docs go deeper on specific pieces and are linked where relevant:

  • Animation Types — the exhaustive per-type / per-runner parameter tables.
  • Concepts — palette internals and the lower-level timing model.

Scenes are JSON. The exact same document is used whether you POST it to play inline, save it for later, or store it as a file — see the HTTP API.


1. The mental model

A scene is a light show. It is built from three nested pieces:

graph TD
  Scene["Scene — the whole show (loops or plays once)"]
  Scene --> L1["Layer · group: ambient · panels: all"]
  Scene --> L2["Layer · group: accent · panels: leaves"]
  L1 --> A1["Step 1 — TRANSITION 3s"]
  L1 --> A2["Step 2 — BREATHE 4s (loop)"]
  L2 --> B1["Step 1 — RIPPLE 2s (source: root)"]
  • A scene holds 1–8 layers that all play at the same time.
  • A layer targets a set of panels and runs a sequence of 1–12 steps, one after another.
  • A step is one animation segment with a duration.

Two kinds of step exist, and the difference matters for how they behave:

Step kind Field Runs where Cost
Panel-local animation "type" On each panel's own chip (ATmega) One setup packet, then zero per-frame traffic
Controller runner "runner" Composed on the controller (ESP) A per-panel pulse setup burst (WAVE/…/BOUNCE); RAIN/SPARKLE emit drop packets over time

A panel-local animation (BREATHE, FADE, …) is told once what to do and runs itself. Most runners (WAVE, RIPPLE, CHASE, WHEEL, BOUNCE) are moving effects the controller compiles into one per-panel pulse and sends once. RAIN and SPARKLE are particle spawners the controller drives over the step window — random drops / flashes, each a self-finishing one-shot pulse. Use panel-local types for per-panel effects; use runners for motion or texture across panels.


2. How the panels are connected — topology

To use targeting and directional effects well, you need the shape of the network.

A Lightnet device is a controller driving a tree of panels over a single I²C bus. During boot the controller discovers the panels and gives each a 1-based index. Index 1 is always the panel wired to the controller (the root); the rest fan out from it.

Text Only
 controller
    [1]  root            depth 0
    ╱ ╲
 [2]   [3]               depth 1
  │      │
 [4]    [5]              depth 2
         [6]             depth 3

For a given physical wiring these indices are stable across reboots — they only change if you physically re-wire the panels. From this tree the controller derives everything targeting and directionality use:

Concept On the example tree
root panel 1 (depth 0)
depth hops from the root: 1→0, 2,3→1, 4,5→2, 6→3
leaves panels with no children: 4, 6
branches panels that fork (≥2 children): 1
neighbors of 3 panels wired directly to it: 1, 5
subtree of 3 itself + everything below: 3, 5, 6
canonical order a stable depth-first walk: 1, 2, 4, 3, 5, 6

Two consequences make scenes portable between devices with different panel counts and wiring:

  1. You can target panels by role in the tree ("the leaves", "two hops from the root") instead of fixed numbers, so the same scene adapts to whatever panels exist.
  2. Moving effects travel by graph distance, so "ripple outward from the centre" means the same thing on any device.

The panel you call "the centre" can be changed per device with the logical root (§10) — without editing the scene.


3. How playback works — timing & choreography

When a scene starts, all panels are cleared to black, then:

  • Every layer without startAfter begins immediately, in parallel.
  • Within a layer, steps play in order; each advances when its duration elapses.
  • A layer with startAfter: "X" stays dark until layer X's whole sequence finishes.
  • A layer with startAfter: "X:stepId" stays dark until just that named step of X's sequence finishes — the rest of X's sequence keeps playing independently.
---
config:
  gantt:
    barHeight: 50
    barGap: 10
    fontSize: 24
    sectionFontSize: 20
---
gantt
  title Two layers, one gated (loop = true)
  dateFormat x
  axisFormat %Lms
  section ambient (group)
  TRANSITION 2s   :0, 2000
  BREATHE 4s      :2000, 6000
  section accent (startAfter ambient)
  (waiting)       :crit, 0, 6000
  WAVE 2s         :6000, 8000

When every non-async layer has finished, the scene-cycle barrier fires:

  • loop: true → the whole scene restarts, all layers together (they never drift apart).
  • loop: false → playback stops on the last frame.

speed scales every step's duration (e.g. 2.0 = twice as fast). A gap step (no type/runner) just waits.

Async modes. The async property on a layer controls how it interacts with the barrier:

async value Loops independently Scene lifecycle
false / absent (default) no — held by barrier participates normally
true / "loop" yes holds the scene open after sync layers finish
"free" yes scene ignores this layer entirely — neither waits for it, nor restarts it

Use "loop" for a background effect that should keep cycling while the foreground scene plays out. Use "free" for a completely independent ambient layer — it runs forever regardless of what the rest of the scene does, and the scene can stop/loop without it. async has no effect when startAfter is set.

The deep timing rules — the barrier, infinite steps — are covered in Concepts → Sequencing & Timing; the property summaries are in §5 below.


4. Scene properties

The top-level object:

Property Required Default What it is
schemaVersion no 1 Format version. Rejected (409) if newer than the firmware (currently 8 — step id + startAfter: "group:stepId", §5/§7.1).
name yes (to save) 1–18 chars, [a-zA-Z0-9_-]. The filename when stored.
loop no false Restart the whole scene when all layers finish.
speed no 1.0 Playback multiplier, clamped to 0.110.0. Scales all durations.
colors no white / black / black The three base colours (primary, secondary, tertiary) referenced by useColor and the "Base colors" palette.
background no #000000 Inline RGB compositor base pushed to every panel at scene start. Layers fold over it, and a panel with no active layer shows it. Great for a static ambient colour under animated accents.
palette no "Base colors" Default palette for layers that don't override it.
layers yes 1–8 layer objects, played simultaneously and composited (§5.1).
JSON
{
  "name": "my_scene",
  "loop": true,
  "speed": 1.0,
  "palette": "Ocean",
  "colors": { "primary": "#10C0FF", "secondary": "#0030A0", "tertiary": "#000000" },
  "layers": [ /* … */ ]
}
  • name is required to save a scene (POST /api/scenes or PATCH /api/scenes/:id); inline play tolerates it but keep it set. Only [a-zA-Z0-9_-], max 18.
  • loop governs the whole scene. To loop a single effect forever instead, use a one-layer scene whose last step is infinite or looped (see §7).
  • speed is global; per-step timing is the step's duration divided by speed.
  • colors + palette feed the colour system (§9). If you omit palette, the scene uses "Base colors", a gradient built live from the three base colours.

5. Layer properties

Each entry in layers:

Property Required Default What it is
group yes A name ("ambient") or number (1–254). Unique within the scene.
panels no "all" Which panels this layer drives — see §6.
blend no firmware default (opaque; runners: max) How this layer composites with the layers below it — see §5.1.
sequence yes 1–12 steps, played in order — see §7.
startAfter no Group name to wait for ("ambient"), or a specific step of it ("ambient:intro", requires schemaVersion: 8) — until it finishes this layer is dark.
async no false Layer looping mode: false/absent = sync, true/"loop" = loop independently (blocks scene), "free" = loop independently (scene ignores it). Ignored if startAfter is set.
palette no scene default Palette override for this layer's panels (see the overlap caveat in §9).
disabled no false When true, this layer is skipped entirely during playback (treated as already finished). Still validated and stored, so it can be re-enabled later.
  • group is the synchronisation unit — all panels in a group start a step together. Prefer names (auto-mapped to IDs in first-seen order); don't mix names and numbers in one scene, and never reuse a group across two layers.
  • startAfter turns the flat "all start at t=0" model into a dependency graph for choreography. The target must exist, can't be the layer itself, can't form a cycle, and the awaited step (or, if none is named, the whole sequence's last step) can't be infinite (it would never finish). A disabled dependency counts as already finished, so dependents start immediately.
  • startAfter: "group:stepId" waits for one specific step of group's sequence to finish, rather than the whole sequence — see §7.1 for the step id property and an example. Requires schemaVersion: 8.
  • async controls independence from the scene barrier — see the async modes table above.
JSON
{
  "group": "ambient",
  "panels": "all",
  "palette": "Ocean",
  "sequence": [
    {
      "type": "BREATHE",
      "colorTo": { "palette": 200 },
      "duration": 4000,
      "loop": true
    }
  ]
}

5.1 Compositing overlapping layers — blend & modifiers

Layers that target the same panel run at the same time and are composited into the panel's single colour, in array order (earlier layers are below later ones). See §11.1 Per-panel animation capacity for how many layers a single panel can run at once and what happens beyond that.

Each layer is either a source (combines its colour with what's below via blend) or a modifier (transforms what's below):

blend Effect Notes
(absent) firmware default opaque for normal layers; max for runners
opaque top wins explicit top-wins, including on runners
add additive light black is transparent
max per-channel lighten black is transparent
multiply darken / mask
screen soft lighten black is transparent
darken per-channel min non-destructive darken; white is transparent
overlay multiply shadows, screen highlights contrast boost
difference per-channel \|below − layer\| inverts toward the layer's colour
subtract below − layer, clamped to 0 punches the layer's colour out of what's below

Omit blend on runner layers to get max so a runner's dark phase shows the background/layers below (a standalone runner over a black base is unchanged). To layer any source over a background, give it add/max/screen. Use explicit "opaque" when a runner should cover what's below during its dark phase too.

Modifier layers are panel-local steps (any type except HUE_CYCLE) with animates set to something other than colordim / desaturate / hue / invert / brighten / saturate. Instead of producing a colour, they animate a scalar fromto (0–255) and reshape everything composited below (see Modifier targets for the full field reference). Put the modifier layer after (above) the layers it should affect:

JSON
"layers": [
  { "group": "base", "panels": "all", "sequence": [ { "type": "SOLID", "color": { "palette": 200 }, "duration": 0 } ] },
  { "group": "dim", "panels": "all", "sequence": [ { "type": "FADE", "animates": "dim", "from": 255, "to": 40, "duration": 3000 } ] }
]

A finished modifier holds its final value; ramp it back to identity to release: 255 for dim/desaturate, 0 for hue/invert, and 0 for brighten/saturate.

brighten/saturate are the inverse of dim/desaturate: identity at 0, pushing toward white / full saturation as the value rises to 255. Use these to brighten or boost saturation of whatever's below — e.g. flaring a dim background brighter, or making a multi-colour background more vivid (where the single-colour screen/overlay tricks don't generalise):

JSON
"layers": [
  { "group": "base", "panels": "all", "sequence": [ { "type": "SOLID", "color": { "palette": 200 }, "duration": 0 } ] },
  { "group": "flare", "panels": "all", "sequence": [ { "type": "FADE", "animates": "saturate", "from": 0, "to": 200, "duration": 1500 } ] }
]

6. Targeting panels — the panels field

panels decides which panels a layer drives. It accepts three families of value, freely combined. All resolve at play time against this device's topology (§2).

6.1 Explicit (device-specific, exact)

JSON
"panels": "all"              // every discovered panel
"panels": [1, 3, 5]          // exactly these 1-based indices (≤ 32, missing ones skipped)
"panels": {"exclude": [2]}   // everything except these

Best when you're authoring for your own fixed setup and want precise control.

6.2 Graph selectors (portable, derived from the tree)

A string token resolved from the topology — these make a scene adapt to any device:

Selector Targets On the §2 tree
"root" the root panel {1}
"leaves" panels with no children {4, 6}
"branches" fork panels (≥2 children) {1}
"depth:N" / "depth:A-B" panels N hops from root (or band A–B) depth:1{2,3}, depth:1-2{2,3,4,5}
"subtree:N" panel N + all its descendants subtree:3{3,5,6}
"neighbors:N" panels wired directly to N neighbors:3{1,5}
"fraction:A-B" a slice of the canonical order, A,B ∈ 0–1 fraction:0-0.5 → first half
"first:K" / "last:K" the first / last K panels in canonical order first:2{1,2}
"even" / "odd" by parity of canonical position

fraction scales with panel count (portable "front half"); first/last:K is an absolute count. depth, subtree, neighbors follow the wiring.

6.3 Composition

Combine any of the above with set algebra:

JSON
"panels": { "any": ["root", "leaves"] }            // union  → {1,4,6}
"panels": { "all": ["subtree:3", "leaves"] }       // intersect → {6}
"panels": { "not": "subtree:3" }                   // complement → {1,2,4}

6.4 What happens when nothing matches

If a selector resolves to no panels on the target device (e.g. subtree:9 where panel 9 doesn't exist), the layer simply contributes nothing — the scene still plays, that layer is skipped. Explicit indices that don't exist are likewise skipped. Nothing errors at play time.

Text Only
panels: "leaves"          panels: "subtree:3"        panels: {not:"subtree:3"}
        [1]                       [1]                        [*1]
       ╱  ╲                      ╱  ╲                       ╱  ╲
    [2]    [3]                [2]   [*3]                  [*2]  [3]
     │      │                  │      │                    │     │
   [*4]    [5]               [4]    [*5]                 [*4]   [5]
            │                         │                          │
          [*6]                      [*6]                        [6]
   → 4, 6                   → 3, 5, 6                   → 1, 2, 4

7. Steps — the sequence

A step is one animation segment. It is either a panel-local animation (type) or a controller runner (runner) — never both — or a gap (neither).

7.1 Common step properties

Property Applies to What it is
id all Optional name for this step, [a-zA-Z0-9_-] (no :), unique within the layer's sequence. Lets other layers target it with startAfter: "group:stepId" (requires schemaVersion: 8). Not sent to panels — parse-time only.
type panel-local Animation name (§7.2). Mutually exclusive with runner.
runner runner WAVE / RIPPLE / CHASE / WHEEL / BOUNCE / RAIN / SPARKLE / MATRIX (§7.3).
color / colorTo both The (target) colour — a colour reference. color is an alias for colorTo.
colorFrom most types Start colour (for fades, breathe, reactive rest, …).
duration all Milliseconds, 0–65535. 0 = infinite, only on the last step.
loop panel-local Repeat this step's animation for its duration.
pingpong panel-local Reverse at the end instead of restarting.
params both Up to 5 bytes (0–255), type-specific. Prefer the named keys below.
source runner Where a moving effect emanates from (§8).
reverse runner Flip the direction (§8).
animates / amount both What the animation/sweep modulates, and (for runners) its peak intensity (§7.3, types.md).
from / to panel-local Scalar ramp endpoints (0–255), used instead of colorFrom/colorTo when animates is not color.

7.2 Panel-local animation types

Set with "type". Summary below; full parameter tables are in Animation Types.

type Does Key params (see types.md)
SOLID Holds one colour
FADE / TRANSITION Linear colorFromcolorTo
BREATHE Smooth oscillation between the two colours
PULSE Rise → hold → fall flash params[0] rise, params[1] fall
BLINK On/off square wave params[0] half-period ms
STROBE Flash at a frequency params[0] Hz
HUE_CYCLE Rainbow rotation (ignores colours) params[0] speed
REACTIVE Jumps to colorTo on a beat, decays to colorFrom params[0] decay rate

A step with neither type nor runner is a gap — a timed hold (panels keep their current colour for duration). Use it to delay a layer or pause between effects.

7.3 Controller runners

Set with "runner". Runners are moving effects that sweep a coordinate across the targeted panels over duration:

runner Does Width
WAVE A bright band travels along the panels waveWidth (rings)
RIPPLE A ring expands outward from the source rippleWidth (rings)
CHASE A single lit ring steps outward
WHEEL Blades rotate continuously about a centre thickness (degrees), lines (1–6)
BOUNCE A bright band bounces back and forth forever width (rings)
RAIN A particle spawner: random drops fall down tree paths, head + fading tail waves (drops/sec), speed (fall time), width (tail rings)
SPARKLE A particle spawner: random panels flash (instant-on + fade) waves (flashes/sec), width (fade)
MATRIX A particle spawner: constant-speed digital-rain (geometric = straight lines, topology = tree path) waves (drops/sec), speed (fall time), width (tail rings), angle

The direction of WAVE/RIPPLE/CHASE/BOUNCE is set by source/reverse — see §8. WHEEL pivots about source the same way (see Animation Types → WHEEL for its specifics — it always spins, needs the geometric layout, and has no topology fallback). RAIN/SPARKLE are spawners, not compiled sweeps: duration is the play window (drops finishing when it ends), waves is the spawn rate (per second), and the effect is genuinely random and non-repeating. RAIN drops fall root→leaf (reverse makes them rise) with speed the fall-time — or, with directionality:geometric + angle, they fall along the planar layout axis (the visual down) instead of the tree. MATRIX is the constant-speed version of RAIN (digital-rain) — same knobs and both directionality modes: geometric draws straight lines (set angle), topology gives a constant-speed tree path. SPARKLE has no directionality (source/reverse/angle ignored) and no speed. See Animation Types → RAIN/SPARKLE/MATRIX for full details (schemaVersion: 7).

JSON
{
  "runner": "WAVE",
  "source": "root",
  "color": { "palette": 200 },
  "waveWidth": 2,
  "duration": 2500
}

Repeating sweeps — count. WAVE/RIPPLE/CHASE fire a fixed number of passes during the step window. Each pass is a full one-shot sweep from the field's origin to its far edge, travelling over duration. Passes are scheduled at evenly spaced offsets:

startMs = duration / count * spawnIndex (spawnIndex zero-based, count 1–30, default 1).

  • count: 1 — one sweep at the start of the step.
  • count: 3 with duration: 1000 — sweeps at 0 ms, 333 ms, and 666 ms.
JSON
{
  "runner": "RIPPLE",
  "source": "root",
  "color": { "palette": 96 },
  "rippleWidth": 1,
  "count": 3,
  "duration": 1500
}

What the sweep animates — animates / amount. By default a runner sweeps color (a per-panel PULSE between color and the background). Set animates to dim / desaturate / hue / invert / brighten / saturate to compile the same PULSE (same envelope as the colour sweep), but ramping between that property's identity value and amount (peak intensity, 0–255) instead — so the wave modulates what's already showing rather than replacing it:

JSON
{
  "runner": "RIPPLE",
  "source": "root",
  "animates": "hue",
  "amount": 120,
  "rippleWidth": 2,
  "duration": 4000
}
animates Modulates amount = peak
color (default) colour (color field)
dim dimming 0 blackout … 255 no change
desaturate desaturation 0 greyscale … 255 no change
hue hue rotation 0…255 = a full turn
invert colour inversion 0 no change … 255 fully inverted
brighten brightening toward white 0 no change … 255 white
saturate saturation boost toward fully saturated 0 no change … 255 fully saturated

See Animation Types → Controller Runners for the full mechanics.


8. Directionality — the source field

A runner needs to know which way to move. Lightnet expresses this as graph distance from a source: each targeted panel gets a coordinate equal to its hop-distance from the source set, and the effect sweeps that coordinate. This is portable — it works on any tree.

Directionality is two independent choices: the field mode ("directionality": "topology" (default) or "geometric") and the "source" it emanates from.

"source" accepts:

source The effect… Coordinate (= distance from)
"root" (default) emanates outward from the root/centre distance from the root
"leaves" converges inward from the tips distance from the nearest leaf
"panel:N" emanates from panel N distance from panel N
"all" topology: every panel pulses together (degenerate). Geometric ripple: one ripple from the geometric centre (see below)

In topology mode (default) "distance" is graph hops; in geometric mode it is physical distance (see below). "reverse": true flips the coordinate, so the effect travels the other way (e.g. a ripple that collapses inward to the root). A source that doesn't exist on a device falls back to root.

Geometric directionality (directionality:"geometric")

Topology mode sweeps along the wiring — great for portability, but it can't express straight motion across the piece, because graph distance has no notion of 2-D direction. "directionality": "geometric" adds that: the controller computes each panel's flat (x,y) position from the regular-polygon geometry of the tree (the same layout the mobile app draws in its visualizer — no setup, no extra hardware, no protocol change). It then behaves differently per runner:

WAVE / CHASE — straight axis sweep. A line at "angle" degrees sweeps across the layout. source is not used here (an axis has no origin, only a direction).

Field Meaning
"angle" Sweep axis in degrees [0,360), measured in the device's computed layout plane. In the app's default (unrotated) view, 0 sweeps horizontally and 90 vertically; the exact on-screen direction also depends on the visualizer's view rotation, so treat the angle as a dial to tune by eye rather than a fixed compass bearing. reverse flips which way the sweep travels along the axis. (2° resolution.)
JSON
{
  "runner": "WAVE",
  "directionality": "geometric",
  "angle": 0,
  "color": { "palette": 128 },
  "waveWidth": 3,
  "duration": 5000
}

RIPPLE — Euclidean rings from the source. A ripple has no axis, so angle is ignored; instead a circle grows from the source centre and lights whatever panel surface it intersects (physical distance, not hops). Each panel is treated as its circumscribed disc, so it occupies a range of distances from the centre: as the ring grows it lights the nearest panel first, keeps it lit while the ring crosses it, and — because neighbouring panels overlap in distance — lights them together during the overlap rather than stepping through one panel at a time. This is where source shines in geometric mode:

source Geometric ripple
"root" (default) one ripple from the root's centroid
"panel:N" one ripple from panel N's centroid
"leaves" one ripple per leaf, all expanding inward at once (fronts meet in the middle)
"all" one ripple from the geometric centre (average centroid of all panels) — a single front growing outward from the middle
JSON
{
  "runner": "RIPPLE",
  "directionality": "geometric",
  "source": "leaves",
  "color": "#30C0FF",
  "rippleWidth": 2,
  "duration": 2500
}

Notes: - Needs "schemaVersion": 3. - The layout frame is anchored deterministically (lowest panel index), so a given angle always produces the same sweep on a given device — but it is not a literal compass bearing; tune by eye. - reverse still applies (axis sweep: flips direction ≈ angle + 180; ripple: rings collapse toward the source instead of expanding). - Width is on the same scale as the graph field, so waveWidth/rippleWidth behave comparably. - If the layout can't be embedded (e.g. degenerate topology), it falls back to topology mode with the same source. - The legacy "source": "geometric" still parses (→ directionality:geometric, axis sweep from the default root).

On the §2 tree, a RIPPLE with source:"root" lights rings outward — {1}{2,3}{4,5}{6}:

Text Only
 t→0      [1]●              t→½    [1]            t→1   [1]
         ╱   ╲                    ╱   ╲                ╱   ╲
       [2]   [3]               [2]●  [3]●           [2]   [3]
        │     │                 │     │              │     │
       [4]   [5]              [4]   [5]            [4]●  [5]●  … then [6]●
              │                       │                          │
             [6]                     [6]                        [6]

source:"leaves" reverses the rings (starts at 4,6 and moves in). waveWidth / rippleWidth are measured in rings (hops), so width 2 means "two hops thick" regardless of device size.

Migrating older scenes: the old originPanel field still works and is read as source:"panel:N". WAVE/CHASE that relied on discovery order now default to source:"root".


9. Colours & palettes

Every colour field (color, colorTo, colorFrom) accepts one of four forms:

Form Example Meaning
Hex string "#FF8000" A literal RGB colour.
Channels { "r": 255, "g": 128, "b": 0 } Literal RGB (any channel omitted = 255).
Palette position { "palette": 200 } Sample the active palette at 0–255.
Base colour { "useColor": 0 } 0=primary, 1=secondary, 2=tertiary from colors.
  • Palettes are 16-stop gradients. Use a built-in name (Rainbow, Lava, Ocean, Forest, Party, Sunset, Aurora, Embers) or one you've created via the API. Full palette schema and the built-in list are in Concepts → Palettes.
  • Base colors (the default palette) is built live from the three base colours, so {"palette":0/128/255} map to primary/secondary/tertiary. Referencing useColor or "Base colors" lets one scene re-skin instantly when the base colours change.
  • Per-layer palette overrides the scene palette for that layer's panels. ⚠ Each panel holds only one active palette — don't point two layers with different palettes at the same panel (last one wins). See the overlap caveat.

10. Per-device topology config (logical root)

A device-local setting lets the same scene land correctly on different hardware. It is not part of the scene — it's set once per device via the Configuration API and persists on the controller.

Logical root

Re-designates which panel counts as the "root" for depth, subtree, and the default runner source:"root". Point it at the panel you think of as the centre and every center-oriented scene re-centres there — no scene edit:

Text Only
 default root = 1                 logical root = 3
        [1]  depth 0                    [1]  depth 1
       ╱   ╲                           ╱   ╲
    [2]     [3]  depth 1            [2]    [*3] depth 0   ← new centre
     │       │                       │       │
    [4]     [5]                    [4]      [5]
              │                              │
             [6]                            [6]

PATCH /api/configuration with {"logicalRoot": 3}. A value that doesn't exist on the device falls back to the physical root.


11. Validation & limits

Saving or playing a scene validates all of these (HTTP 422 with a message on failure):

Rule Limit
Scene name [a-zA-Z0-9_-], 1–18 chars; required to save
schemaVersion ≤ firmware version (currently 8) — else 409 schema_too_new
speed clamped to 0.1–10.0
Layers per scene 1–8
group unique across layers; name or 1–254 (don't mix)
Steps per layer 1–12
type + runner mutually exclusive
duration 0–65535 ms; 0 only on the last step of a layer
Explicit panel list indices 1–255, ≤ 32 per layer
source root / leaves / panel:N
params ≤ 4 entries, each 0–255
startAfter existing group, no self-reference, no cycle, target not infinite

11.1 Per-panel animation capacity

Layers run per panel — every panel that a layer targets gets its own copy of that layer's current step, animating independently and then composited together (§5.1). Each panel has a fixed budget of 18 concurrently running animations, shared by everything currently active on that panel:

  • Panel-local steps and runner pulses. Each layer occupies one of these slots on every panel it targets, for as long as that layer's current step is active there. A scene has at most 8 layers, so ordinary layers alone never get close to the 18-slot ceiling.
  • RAIN / SPARKLE / MATRIX particle spawners are different. Each in-flight drop or flash is its own animation, drawn from the same 18-slot budget. A spawner step doesn't reserve a fixed number of slots up front — it just keeps spawning new drops/flashes into whatever capacity is free, so its effective density depends on how many slots other layers are using on the same panels at that moment.

What this means in practice:

  • A handful of ordinary layers plus one or two spawner layers on the same panels is the normal case and has plenty of headroom.
  • If you stack many layers (panel-local and runner) onto the same panels — especially several together with spawner layers (RAIN/SPARKLE/MATRIX) — and their active steps overlap in time, a panel can run out of capacity. When that happens the panel silently drops the excess: a layer's step simply doesn't show on that panel until a slot frees up. Nothing errors and the rest of the scene keeps playing.
  • Spawners specifically get starved rather than dropped: with less free capacity, fewer drops/flashes are in flight at once, so RAIN/SPARKLE/MATRIX can look sparser than expected when many other layers are simultaneously animating the same panels. Reduce the number of simultaneously-active layers covering those panels, or narrow the spawner's panels selector, to give it more room.
  • Layers that don't overlap in time (e.g. gated with startAfter, or simply not targeting the same panels) don't compete for the same budget — only animations that are actually running on the same panel at the same moment count against the 18.

For the underlying mechanics (slot allocation, composite ordering, spawner pools), see AnimationPlayer.hpp (MAX_ANIM_SLOTS) and ScenePlayer.cpp (allocSpawnPools).

This 18-slot panel-side budget is independent of the controller-side AnimationScheduler (MAX_ACTIVE_RUNNERS = 8), which tracks one in-flight runner (WAVE/RIPPLE/CHASE/WHEEL/ BOUNCE/RAIN/SPARKLE/MATRIX) per layer. Since a scene has at most 8 layers, this cap is never actually reached — it just lets the controller pre-size its runner list at scene load instead of growing it one layer at a time.


12. Example scene library

Copy-paste starting points. Every example here is valid against §11. Selectors/source make the topology-aware ones portable; the index-based ones assume your own wiring.

Solid hold

JSON
{
  "name": "solid_warm",
  "layers": [
    {
      "group": "g",
      "panels": "all",
      "sequence": [
        {
          "type": "SOLID",
          "color": "#FF6000",
          "duration": 0
        }
      ]
    }
  ]
}

Breathing, base-colour driven (re-skins with appearance)

JSON
{
  "name": "breathe",
  "loop": true,
  "colors": {
    "primary": "#0080FF",
    "secondary": "#000000",
    "tertiary": "#000000"
  },
  "layers": [
    {
      "group": "ambient",
      "panels": "all",
      "sequence": [
        {
          "type": "BREATHE",
          "colorFrom": { "useColor": 1 },
          "colorTo": { "useColor": 0 },
          "duration": 4000,
          "loop": true
        }
      ]
    }
  ]
}

Background + accent (two layers, no overlap)

JSON
{
  "name": "bg_accent",
  "loop": true,
  "palette": "Ocean",
  "layers": [
    {
      "group": "bg",
      "panels": { "not": "leaves" },
      "sequence": [
        {
          "type": "BREATHE",
          "colorTo": { "palette": 160 },
          "duration": 5000,
          "loop": true
        }
      ]
    },
    {
      "group": "accent",
      "panels": "leaves",
      "sequence": [
        {
          "type": "PULSE",
          "colorFrom": "#000000",
          "colorTo": "#FFFFFF",
          "duration": 900,
          "loop": true,
          "params": [64, 96]
        }
      ]
    }
  ]
}

Fade chain that loops

JSON
{
  "name": "fade_chain",
  "loop": true,
  "layers": [
    {
      "group": "g",
      "panels": "all",
      "sequence": [
        {
          "type": "FADE",
          "colorFrom": "#FF0000",
          "colorTo": "#00FF00",
          "duration": 1500
        },
        {
          "type": "FADE",
          "colorFrom": "#00FF00",
          "colorTo": "#0000FF",
          "duration": 1500
        },
        {
          "type": "FADE",
          "colorFrom": "#0000FF",
          "colorTo": "#FF0000",
          "duration": 1500
        }
      ]
    }
  ]
}

Wave outward from the centre (portable)

JSON
{
  "name": "wave_out",
  "loop": true,
  "palette": "Lava",
  "layers": [
    {
      "group": "g",
      "panels": "all",
      "sequence": [
        {
          "runner": "WAVE",
          "source": "root",
          "color": { "palette": 210 },
          "waveWidth": 2,
          "duration": 2500
        }
      ]
    }
  ]
}

Ripple from a specific panel

JSON
{
  "name": "ripple_p3",
  "loop": true,
  "layers": [
    {
      "group": "g",
      "panels": "all",
      "sequence": [
        {
          "runner": "RIPPLE",
          "source": "panel:3",
          "color": "#30C0FF",
          "rippleWidth": 2,
          "duration": 1800
        }
      ]
    }
  ]
}

Ripple that collapses inward (reverse)

JSON
{
  "name": "ripple_in",
  "loop": true,
  "layers": [
    {
      "group": "g",
      "panels": "all",
      "sequence": [
        {
          "runner": "RIPPLE",
          "source": "leaves",
          "color": "#FFB050",
          "rippleWidth": 2,
          "duration": 2000
        }
      ]
    }
  ]
}

Chase around the leaves

JSON
{
  "name": "chase_leaves",
  "loop": true,
  "layers": [
    {
      "group": "g",
      "panels": "leaves",
      "sequence": [
        {
          "runner": "CHASE",
          "source": "root",
          "color": { "useColor": 0 },
          "duration": 1200
        }
      ]
    }
  ]
}

Choreography with startAfter

JSON
{
  "name": "intro_then_main",
  "loop": true,
  "layers": [
    {
      "group": "intro",
      "panels": "root",
      "sequence": [
        {
          "type": "PULSE",
          "colorFrom": "#000000",
          "colorTo": "#FFFFFF",
          "duration": 700
        }
      ]
    },
    {
      "group": "main",
      "startAfter": "intro",
      "panels": "all",
      "sequence": [
        {
          "runner": "WAVE",
          "source": "root",
          "color": "#00FFAA",
          "waveWidth": 2,
          "duration": 2500
        }
      ]
    }
  ]
}

Async background + synced foreground

JSON
{
  "name": "async_bg",
  "loop": true,
  "layers": [
    {
      "group": "bg",
      "panels": "all",
      "async": true,
      "sequence": [
        {
          "type": "BREATHE",
          "colorTo": "#001830",
          "duration": 6000,
          "loop": true
        }
      ]
    },
    {
      "group": "fg",
      "panels": "leaves",
      "sequence": [
        {
          "runner": "RIPPLE",
          "source": "root",
          "color": "#FF4080",
          "rippleWidth": 1,
          "duration": 1500
        }
      ]
    }
  ]
}

Depth rings (one colour per ring)

JSON
{
  "name": "depth_rings",
  "colors": {
    "primary": "#FF3000",
    "secondary": "#FFD000",
    "tertiary": "#00A0FF"
  },
  "layers": [
    {
      "group": "r0",
      "panels": "depth:0",
      "sequence": [
        {
          "type": "SOLID",
          "color": { "useColor": 0 },
          "duration": 0
        }
      ]
    },
    {
      "group": "r1",
      "panels": "depth:1",
      "sequence": [
        {
          "type": "SOLID",
          "color": { "useColor": 1 },
          "duration": 0
        }
      ]
    },
    {
      "group": "r2",
      "panels": "depth:2-9",
      "sequence": [
        {
          "type": "SOLID",
          "color": { "useColor": 2 },
          "duration": 0
        }
      ]
    }
  ]
}

Reactive (music) — trigger over WebSocket

JSON
{
  "name": "reactive",
  "layers": [
    {
      "group": "beat",
      "panels": "all",
      "sequence": [
        {
          "type": "REACTIVE",
          "colorFrom": "#100010",
          "colorTo": "#FF00C0",
          "duration": 0,
          "params": [200]
        }
      ]
    }
  ]
}

Send beats with POST /api/animations/trigger or the WebSocket ANIMATION_TRIGGER command — see API → Reactive trigger.

Full showcase (parallel + gated + async + runner)

JSON
{
  "name": "showcase",
  "loop": true,
  "palette": "aurora",
  "colors": {
    "primary": "#00FFC0",
    "secondary": "#8000FF",
    "tertiary": "#000000"
  },
  "layers": [
    {
      "group": "ambient",
      "panels": "all",
      "async": true,
      "sequence": [
        {
          "type": "BREATHE",
          "colorFrom": "#000010",
          "colorTo": { "palette": 90 },
          "duration": 7000,
          "loop": true
        }
      ]
    },
    {
      "group": "spine",
      "panels": { "not": "leaves" },
      "sequence": [
        {
          "duration": 500
        },
        {
          "runner": "WAVE",
          "source": "root",
          "color": { "palette": 220 },
          "waveWidth": 2,
          "duration": 2600
        }
      ]
    },
    {
      "group": "tips",
      "startAfter": "spine",
      "panels": "leaves",
      "sequence": [
        {
          "type": "PULSE",
          "colorFrom": "#000000",
          "colorTo": "#FFFFFF",
          "duration": 600,
          "params": [40, 120]
        }
      ]
    }
  ]
}