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.
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:
- 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.
- 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
startAfterbegins immediately, in parallel. - Within a layer, steps play in order; each advances when its
durationelapses. - A layer with
startAfter: "X"stays dark until layerX's whole sequence finishes. - A layer with
startAfter: "X:stepId"stays dark until just that named step ofX's sequence finishes — the rest ofX'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.1–10.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). |
{
"name": "my_scene",
"loop": true,
"speed": 1.0,
"palette": "Ocean",
"colors": { "primary": "#10C0FF", "secondary": "#0030A0", "tertiary": "#000000" },
"layers": [ /* … */ ]
}
nameis required to save a scene (POST /api/scenesorPATCH /api/scenes/:id); inline play tolerates it but keep it set. Only[a-zA-Z0-9_-], max 18.loopgoverns the whole scene. To loop a single effect forever instead, use a one-layer scene whose last step is infinite orlooped (see §7).speedis global; per-step timing is the step'sdurationdivided byspeed.colors+palettefeed the colour system (§9). If you omitpalette, 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. |
groupis 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.startAfterturns 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 ofgroup's sequence to finish, rather than the whole sequence — see §7.1 for the stepidproperty and an example. RequiresschemaVersion: 8.asynccontrols independence from the scene barrier — see the async modes table above.
{
"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 color — dim / desaturate / hue / invert / brighten / saturate.
Instead of producing a colour, they animate a scalar from→to (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:
"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):
"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)¶
"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:
"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.
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 colorFrom→colorTo |
— |
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).
{
"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: 3withduration: 1000— sweeps at 0 ms, 333 ms, and 666 ms.
{
"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:
{
"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.) |
{
"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 |
{
"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}:
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
originPanelfield still works and is read assource:"panel:N".WAVE/CHASEthat relied on discovery order now default tosource:"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. ReferencinguseColoror"Base colors"lets one scene re-skin instantly when the base colours change.- Per-layer
paletteoverrides 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:
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
panelsselector, 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¶
{
"name": "solid_warm",
"layers": [
{
"group": "g",
"panels": "all",
"sequence": [
{
"type": "SOLID",
"color": "#FF6000",
"duration": 0
}
]
}
]
}
Breathing, base-colour driven (re-skins with appearance)¶
{
"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)¶
{
"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¶
{
"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)¶
{
"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¶
{
"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)¶
{
"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¶
{
"name": "chase_leaves",
"loop": true,
"layers": [
{
"group": "g",
"panels": "leaves",
"sequence": [
{
"runner": "CHASE",
"source": "root",
"color": { "useColor": 0 },
"duration": 1200
}
]
}
]
}
Choreography with startAfter¶
{
"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¶
{
"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)¶
{
"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¶
{
"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)¶
{
"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]
}
]
}
]
}