8 · The blaster
Everything works. This part is the difference between a demonstration and a prop: three additions, none of them new machinery.
1 · Segments — one strip, several devices
Section titled “1 · Segments — one strip, several devices”Open the barrel device card and click + add segment twice. Name the
first core with len 6, and the second muzzle with len 2.
@core and @muzzle are now devices like any other, addressable
independently, and @barrel still addresses all eight. Three things happen for
free:
- The
countbox disappears, replaced by8 px · 2 segments. The strip’s length is the sum of its regions, so there is nothing left to get wrong. - Start indices are computed and printed beside each row. You never hand-count offsets.
- Every device dropdown in the show now offers
@coreand@muzzle.
This is what fixes the muzzle flash. Until now the white hit and the travelling
comet were both on @barrel, fighting over the same eight pixels. Point the
set at @muzzle and the comet at @core, set the set’s flow to
parallel, and they simply layer — awaited, so the script still knows about the
flash, but not gating, so the comet starts on the same frame.
2 · A boot sequence
Section titled “2 · A boot sequence”Add a bootSeq script:
| # | Verb | Set | flow |
|---|---|---|---|
| 1 | play |
powerup.wav, vol 0.55 |
detached |
| 2 | chase |
@barrel, #2ca7ff, off #000308, sec 0.05, stop after 0.9 |
serial |
| 3 | set |
@muzzle, color #ffffff, sec 0.1 |
serial |
| 4 | wait |
0.15 |
serial |
Then add a Boot state, tick its make initial box, put run ▸ bootSeq on
on enter, and add when bootSeq.done → Ready.
A state the show passes through once. It costs four rows and one card, and it is
most of why a prop feels built rather than switched on — and because it is
run, not start, the show moves to Ready on the exact frame the sequence
ends.
3 · A low-ammo warning
Section titled “3 · A low-ammo warning”Open the ammo number on the hudView card and replace its colour expression
with:
ammo <= 0 ? 0xff3b30 : (ammo <= 3 ? hsv(0.11, 1, 0.45 + 0.55*(sin(time*9) > 0)) : 0x00e0a0)Nested ternaries, read outward: empty is red; three or fewer pulses amber;
otherwise green. The pulse is sin(time*9) > 0, which is a square wave — the
comparison returns 0 or 1, and that 0/1 is multiplied into the brightness.
That is the pattern worth taking away from this whole series: anything that
can be computed from a number should be, rather than sequenced. A blinking
warning built as a script would need starting, stopping and a state to own it.
Built as an expression it needs nothing, and it is correct the moment ammo
changes for any reason at all.
The finished show
Section titled “The finished show”
It is in the Studio as Project ▾ ▸ Examples ▸ RFX Blaster (tutorial) —
open it, take it apart, change the colours, give it a third fire mode.
{
// ===========================================================
// BLASTER · STEP 8 — The blaster
//
// Everything from steps 2-7, plus the three things that make
// a prop feel finished rather than demonstrated:
//
// SEGMENTS one physical strip, two named regions, so the
// muzzle can flash without the core losing its glow
// A BOOT it powers up before it is usable
// A WARNING the ammo counter pulses when you are nearly out
//
// Eight pixels on GPIO16, a 320x170 panel, two buttons.
// Nothing here is specific to this board but the numbers.
// ===========================================================
"schemaVersion": 1,
"name": "RFX Blaster",
"board": "t-display-s3",
"vars": { "heat": 0, "ambient": 0, "ammo": 12, "charge": 0, "venting": 0 },
"devices": {
// ONE strip, split into named regions. List the segments in
// order with just their counts: each start index is computed
// (0 and 6), and the strip's own `count` defaults to the sum.
// @core and @muzzle are now devices like any other.
"barrel": {
"kind": "ledstrip", "pin": 16, "order": "GRB", "brightness": 160,
"segments": [
{ "id": "core", "count": 6 },
{ "id": "muzzle", "count": 2 }
]
},
"hud": { "kind": "screen", "width": 320, "height": 170 }
},
"inputs": {
"trigger": { "label": "TRIGGER", "color": "#ff3b30", "pin": 14, "activeLow": true, "pull": "up" },
"reload": { "label": "RELOAD", "color": "#2ca7ff", "pin": 0, "activeLow": true }
},
"scripts": {
// ---- THE HUD -------------------------------------------------
// One script, started at boot and never stopped. Every child is
// a LAYER (`parallel`), drawn in order onto a canvas that is
// cleared by the first of them.
"hudView": {
"autostart": true,
"children": [
{ "fill": "@hud", "color": "#000000", "flow": "parallel" },
// Ammo. Under four rounds the number pulses amber — one
// expression, no extra state, no timer.
{ "text": "@hud", "value": "AMMO", "x": 12, "y": 14, "size": 1,
"color": "#4a5560", "flow": "parallel" },
{ "number": "@hud", "value": "ammo", "digits": 2, "x": 12, "y": 28, "size": 4, "flow": "parallel",
"color": "ammo <= 0 ? 0xff3b30 : (ammo <= 3 ? hsv(0.11, 1, 0.45 + 0.55*(sin(time*9) > 0)) : 0x00e0a0)" },
{ "text": "@hud", "value": "HEAT", "x": 12, "y": 118, "size": 1,
"color": "#4a5560", "flow": "parallel" },
{ "meter": "@hud", "value": "heat", "min": 0, "max": 1,
"x": 12, "y": 132, "w": 120, "h": 16,
"color": "hsv(0.33 - heat*0.33, 1, 1)", "flow": "parallel" },
// The reticle. The ring tightens and the dot swells as the
// shot charges, because `r` is a number and any number in a
// show can be an expression.
{ "circle": "@hud", "x": 215, "y": 85, "r": "42 - charge*12", "outline": true, "flow": "parallel",
"color": "hsv(0.33 - heat*0.33, 0.9, 0.9)" },
{ "circle": "@hud", "x": 215, "y": 85, "r": "3 + charge*4", "flow": "parallel",
"color": "hsv(0.12 - charge*0.12, 0.9, 1)" },
{ "line": "@hud", "x0": 215, "y0": 30, "x1": 215, "y1": 39, "flow": "parallel",
"color": "hsv(0.33 - heat*0.33, 0.9, 0.9)" },
{ "line": "@hud", "x0": 215, "y0": 131, "x1": 215, "y1": 140, "flow": "parallel",
"color": "hsv(0.33 - heat*0.33, 0.9, 0.9)" },
{ "line": "@hud", "x0": 160, "y0": 85, "x1": 169, "y1": 85, "flow": "parallel",
"color": "hsv(0.33 - heat*0.33, 0.9, 0.9)" },
{ "line": "@hud", "x0": 261, "y0": 85, "x1": 270, "y1": 85, "flow": "parallel",
"color": "hsv(0.33 - heat*0.33, 0.9, 0.9)" },
// Annunciators. Each word owns its own slot and is lit by
// its own expression — never stacked and blacked out, or a
// black glyph punches holes in the bright word beneath it.
{ "text": "@hud", "value": "READY", "x": 150, "y": 150, "size": 1, "flow": "parallel",
"color": "venting == 0 && ammo > 0 ? 0x00e0a0 : 0x1d2a24" },
{ "text": "@hud", "value": "EMPTY", "x": 200, "y": 150, "size": 1, "flow": "parallel",
"color": "ammo <= 0 ? 0xff3b30 : 0x2a1d1d" },
{ "text": "@hud", "value": "VENT", "x": 252, "y": 150, "size": 1, "flow": "parallel",
"color": "venting ? 0xffb000 : 0x2a2418" }
]
},
// ---- BOOT ----------------------------------------------------
// It powers up before it works. A chase up the barrel, a flash
// off the muzzle, and the HUD comes alive behind it.
"bootSeq": {
"children": [
{ "play": "powerup.wav", "volume": 0.55, "flow": "detached" },
{ "chase": "@barrel", "color": "#2ca7ff", "off": "#000308", "sec": 0.05, "for": 0.9 },
{ "set": "@muzzle", "color": "#ffffff", "sec": 0.1 },
{ "wait": 0.15 }
]
},
// ---- IDLE ----------------------------------------------------
// The core breathes with heat; the muzzle keeps a dim ember so
// the barrel does not read as dead when the core is cold.
"idle": {
"children": [
{ "follow": "heat", "target": "ambient", "rate": 0.22 },
{ "pattern": "@core", "loop": true,
"color": "hsv(0.09 - heat*0.09, 1, 0.16 + 0.55*heat + 0.10*sin(time*3 + i*0.7))" },
{ "pattern": "@muzzle", "loop": true,
"color": "hsv(0.09 - heat*0.09, 1, 0.10 + 0.70*heat)" }
]
},
// ---- CHARGE --------------------------------------------------
// The whine is one tone whose frequency is an expression, so it
// climbs with `charge` and needs no audio file. A tone with no
// `seconds` runs until it is stopped, so it must be detached —
// nothing can wait for a sound with no end. The `tween` is the
// only serial child, so the tween is what times a full charge.
"chargeUp": {
"children": [
{ "tone": "260 + charge*1100", "wave": "saw",
"volume": "0.08 + charge*0.22", "flow": "detached" },
{ "pattern": "@barrel", "loop": true,
"color": "i/count < charge ? hsv(0.12 - charge*0.12, 1, 1) : 0x060100" },
{ "tween": "charge", "to": 1, "sec": 1.4, "ease": "in" }
]
},
// ---- FIRING --------------------------------------------------
// The muzzle flash and the ejecting comet are on DIFFERENT
// devices, so they layer instead of fighting: @muzzle gets the
// white hit, @core gets the travelling glow.
"shot": {
"children": [
{ "play": "blaster.wav", "volume": 0.5, "flow": "detached" },
{ "set": "@muzzle", "color": "#ffffff", "sec": 0.05, "flow": "parallel" },
{ "comet": "@core", "color": "hsv(0.09 - heat*0.09, 0.9, 1)",
"lapSec": 0.2, "tail": 5, "for": 0.2 },
{ "wait": 0.06 }
]
},
// One script for a tap and for a full-power blast: every
// parameter is scaled by `charge`.
"heavyShot": {
"children": [
{ "play": "blaster.wav", "volume": "0.4 + charge*0.5", "flow": "detached" },
{ "tone": 150, "wave": "saw", "seconds": "0.2 + charge*0.3",
"volume": "0.10 + charge*0.25", "flow": "detached" },
{ "set": "@muzzle", "color": "#ffffff", "sec": "0.06 + charge*0.12", "flow": "parallel" },
{ "comet": "@core", "color": "hsv(0.12 - charge*0.12, 0.9, 1)",
"lapSec": 0.18, "tail": 6, "for": "0.2 + charge*0.3" },
{ "set": "charge", "to": 0 },
{ "wait": 0.06 }
]
},
"dryFire": {
"children": [
{ "play": "click.wav", "volume": 0.5, "flow": "detached" },
{ "wait": 0.12 }
]
},
"vent": {
"children": [
{ "play": "powerdown.wav", "volume": 0.6, "flow": "detached" },
{ "ramp": "heat", "to": 0, "rate": 0.5, "flow": "detached" },
{ "blink": "@barrel", "color": "#4d8cff", "off": "#02060f", "sec": 0.3, "for": 2.2 }
]
},
"reloadSeq": {
"children": [
{ "play": "powerup.wav", "volume": 0.6, "flow": "detached" },
{ "chase": "@barrel", "color": "#00e0a0", "off": "#001a12", "sec": 0.06, "for": 0.7 },
{ "set": "ammo", "to": 12 }
]
}
},
// One rule, watched in every state, fired once on the rising edge.
"rules": [
{ "when": "heat >= 1", "to": "Overheated" }
],
"states": {
"initial": "Boot",
"Boot": {
"enter": [ { "run": "bootSeq" } ],
"on": { "bootSeq.done": "Ready" }
},
"Ready": {
"enter": [ { "start": "idle" } ],
"exit": [ { "stop": "idle" } ],
"on": {
"trigger.tapped": { "to": "Firing", "when": "heat < 1 && ammo > 0" },
"trigger.held": { "to": "Charging", "when": "heat < 1 && ammo > 0" },
"trigger.pressed": { "to": "Dry", "when": "ammo <= 0" },
"reload.tapped": { "to": "Reloading", "when": "ammo < 12" }
}
},
"Charging": {
"enter": [ { "start": "chargeUp" } ],
"exit": [ { "stop": "chargeUp" } ],
"on": { "trigger.released": "HeavyFiring" }
},
"Firing": {
"enter": [
{ "set": "heat", "to": "min(heat + 0.17, 1)" },
{ "set": "ammo", "to": "max(ammo - 1, 0)" },
{ "run": "shot" }
],
"on": { "shot.done": "Ready" }
},
"HeavyFiring": {
"enter": [
{ "set": "heat", "to": "min(heat + 0.17 + charge*0.3, 1)" },
{ "set": "ammo", "to": "max(ammo - 1 - floor(charge*2), 0)" },
{ "run": "heavyShot" }
],
"on": { "heavyShot.done": "Ready" }
},
"Dry": { "enter": [ { "run": "dryFire" } ], "on": { "dryFire.done": "Ready" } },
"Reloading": { "enter": [ { "run": "reloadSeq" } ], "on": { "reloadSeq.done": "Ready" } },
"Overheated": {
"enter": [ { "set": "venting", "to": 1 }, { "run": "vent" } ],
"exit": [ { "set": "venting", "to": 0 } ],
"on": { "vent.done": "Ready" }
}
}
}
What you now know
Section titled “What you now know”Everything in the left-hand column below is a card, a dropdown or a box in the editor. The right-hand column is what it is called in the file, for when you read one.
| In the editor | In the file |
|---|---|
A device card, and + add segment inside it |
devices, segments |
| A variable card with a starting value | vars |
| An input card: type, wiring, pin, activeLow, pull | inputs |
| A script card, and the list of step rows in it | scripts, children |
| The flow dropdown on a row | flow: serial / parallel / detached |
| The loop checkbox and its count | loop: true or N |
+ timing / condition ▸ stop after / delay start / repeat every / run only while |
for after every while |
| A state card: on enter, when … → …, on exit | states, enter, on, exit |
The if box on a when row |
when (the guard) |
| A rule card: when · do · then go | rules |
| Any number or colour box, with an expression typed into it | a quoted expression |
That is the whole engine. Everything else in the reference is more verbs.
Next: 9 · Onto hardware — the same file, on a real board.