Skip to content

8 · The blaster

Everything works. This part is the difference between a demonstration and a prop: three additions, none of them new machinery.

Live The finished blaster. Click once for sound, then watch it boot. Tap to fire, hold to charge, RELOAD to refill — and try emptying the magazine.

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.

The barrel device card in the Studio with kind ledstrip, pin 16, a note reading '8 px · 2 segments' where the count box used to be, order GRB, brightness 160, and a segments panel listing '@core len 6 @0-5' and '@muzzle len 2 @6-7' above an 'add segment' button.
Two clicks and two names. The start index of each is worked out for you, and shown: @0‑5 and @6‑7.

@core and @muzzle are now devices like any other, addressable independently, and @barrel still addresses all eight. Three things happen for free:

  • The count box disappears, replaced by 8 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 @core and @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.

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.

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.

The HUD with the ammo counter showing a low count in amber, the heat bar partly filled, and READY still lit.
Under four rounds. No timer, no extra state, no second script — one box.

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 live preview showing TRIGGER and RELOAD buttons, the HUD with AMMO 12 and a green reticle, and below it the barrel strip on GPIO16 broken out into core 0-5 and muzzle 6-7.
The preview shows the split: one physical strip on GPIO16, two named regions under it.

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.

step-08-the-blaster.json 245 lines
{
  // ===========================================================
  //  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" }
    }
  }
}
The Studio Editor tab with the finished blaster loaded, showing the devices section with the segmented barrel and the hud screen, and the live preview at right.
Two hundred and forty-five lines you never typed, and it runs identically on a board.

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.