Skip to content

7 · Sound

Two verbs make a noise, both in the palette’s Sound group. play plays a file; tone synthesises a frequency. The second one is the interesting one, because a frequency can be an expression.

Live Click the preview once to enable sound. Tap TRIGGER for a normal shot; HOLD it and listen to the whine climb, then let go. Empty the magazine for a dry click.

Across the top of the Studio is a fold marked ▶ Sounds. Open it and you get the stock clip library: click the ▶ beside a clip to hear it, then use its name in a play step. blaster.wav, charge.wav, powerup.wav, powerdown.wav, click.wav, zap.wav and alarm.wav are enough to build a prop without recording anything.

A play step’s file box is a picker of those clips, not a text field you have to spell correctly.

Add a play to the shot script: file blaster.wav, vol 0.5, and set its flow to detached.

play can also name a folder or a list, and picks at random. That is easiest to write in the Show tab:

{ "play": "sounds/voices" } // any clip in that folder
{ "play": ["hit1.wav", "hit2.wav", "hit3.wav"] } // any of these three
{ "play": ["hit1.wav", "hit2.wav"], "pick": "sequence" } // round-robin instead

A tone row is: a frequency box, a wave dropdown (sine / square / saw / tri), seconds, and volume. No file, no storage. On a board with a few hundred kilobytes of flash, a prop built out of tones rather than samples is a genuinely different proposition.

Leave seconds empty and the tone plays until something stops it — which is what the charge-up needs.

Add a var charge, then a script chargeUp with three children:

The chargeUp script card in the Studio with three step rows: a tone row whose frequency box contains the expression 260 + charge*1100, wave saw, an empty seconds box, a volume box containing 0.08 + charge*0.22 and flow detached; a pattern row on @barrel with a long colour expression and loop ticked; and a tween row animating charge to 1 over 1.4 s with ease set to 'in'.
The frequency box holds an expression, not a number. That is the whole trick.
# Verb Set flow
1 tone frequency 260 + charge*1100, wave saw, seconds blank, volume 0.08 + charge*0.22 detached
2 pattern @barrel, colour i/count < charge ? hsv(0.12 - charge*0.12, 1, 1) : 0x060100, loop ticked —
3 tween charge → 1 over 1.4 s, ease in serial

charge tweens from 0 to 1 over 1.4 seconds; the tone’s frequency expression is re-read every frame, so the pitch climbs with it from 260 Hz to 1360 Hz. Nothing is being pitch-shifted and there is no audio file — the oscillator is simply told a different number each frame.

The second row is a bar graph made out of a strip: i/count < charge is true for the pixels below the charge level and false above it, so the ternary lights them and dims the rest.

And every flow is load-bearing again:

  • The tone is detached because it has no seconds. A parent cannot wait for a sound with no end; make it serial and the script hangs forever.
  • The pattern loops, so it detaches itself and just runs.
  • The tween is the only serial child, which makes it the thing that decides how long a full charge takes.

On the Ready card, add two more handlers beside the one from part 4:

when then go → if
trigger.tapped Firing heat < 1 && ammo > 0
trigger.held Charging heat < 1 && ammo > 0
trigger.pressed Dry ammo <= 0

Then a Charging state: on enter start ▸ chargeUp, on exit stop ▸ chargeUp, and when trigger.released → HeavyFiring.

Leaving Charging stops chargeUp, which kills the endless tone — that is what on exit is for. Release early and you fire at partial charge, because charge is wherever the tween had got to.

The heavyShot script scales by charge in six places at once — every one of these goes in an ordinary parameter box:

Row Box Expression
play vol 0.4 + charge*0.5
tone seconds 0.2 + charge*0.3
tone volume 0.10 + charge*0.25
set sec 0.05 + charge*0.10
comet colour hsv(0.12 - charge*0.12, 0.9, 1)
comet stop after 0.2 + charge*0.3

Louder, longer, brighter, redder, with a low saw underneath. A tap and a full-power blast are the same four rows.

The cost scales too, on the HeavyFiring state’s on enter:

set heat = min(heat + 0.17 + charge*0.3, 1)
set ammo = max(ammo - 1 - floor(charge*2), 0)

A full charge burns three rounds and nearly half the heat budget. Game design, in two boxes.

Open the Live tab and drag master speed to 0.5 while the charge-up runs. The whine drops an octave along with everything else, because audio is inside the same time domain as the lights. Put 0.6 in a script card’s speed box and the same thing happens to just that subtree.

The live preview showing TRIGGER and RELOAD buttons, the HUD screen with reticle and ammo counter, and the barrel strip below.
Everything so far, in one panel.
step-07-sound.json 199 lines
{
  // ===========================================================
  //  BLASTER · STEP 7 — Sound
  //  Two ways to make a noise: `play` a file, or `tone` a
  //  frequency the engine synthesises. A tone costs no storage
  //  and it can be an EXPRESSION, which is how the blaster gets
  //  a charge-up whine that tracks a number.
  // ===========================================================
  "schemaVersion": 1,
  "name": "Blaster 07 - Sound",
  "board": "t-display-s3",

  "vars": { "heat": 0, "ambient": 0, "ammo": 12, "charge": 0, "venting": 0 },

  "devices": {
    "barrel": { "kind": "ledstrip", "count": 8, "pin": 16, "order": "GRB", "brightness": 160 },
    "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": {
    "hudView": {
      "autostart": true,
      "children": [
        { "fill": "@hud", "color": "#000000", "flow": "parallel" },

        { "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,
          "color": "ammo > 0 ? 0x00e0a0 : 0xff3b30", "flow": "parallel" },

        { "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 ring TIGHTENS as the shot charges: `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)" },

        { "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" }
      ]
    },

    "idle": {
      "children": [
        { "follow": "heat", "target": "ambient", "rate": 0.22 },
        { "pattern": "@barrel", "loop": true,
          "color": "hsv(0.09 - heat*0.09, 1, 0.16 + 0.55*heat + 0.10*sin(time*3 + i*0.7))" }
      ]
    },

    // ---- HOLD TO CHARGE -------------------------------------------
    // The whine is ONE tone whose frequency is an expression. As
    // `charge` tweens 0 -> 1 the pitch climbs with it, because the
    // expression is re-read every frame. There is no audio file
    // here and nothing is being pitch-shifted.
    //
    // A `tone` with no `seconds` plays until something stops it, so
    // it has to be DETACHED: a parent cannot wait for a sound with
    // no end. The `tween` is the only serial child, so the tween is
    // what decides how long a full charge takes.
    "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" }
      ]
    },

    // A tap. `play` names a file in the show folder — in the
    // Studio, anything listed in the Sounds panel.
    "shot": {
      "children": [
        { "play": "blaster.wav", "volume": 0.5, "flow": "detached" },
        { "set": "@barrel", "color": "#ffffff", "sec": 0.04 },
        { "comet": "@barrel", "color": "hsv(0.09 - heat*0.09, 0.9, 1)", "lapSec": 0.2, "tail": 5, "for": 0.2 },
        { "wait": 0.07 }
      ]
    },

    // A charged shot. Every parameter is scaled by `charge`, so one
    // script covers both a tap and a full-power blast: louder,
    // longer, brighter, with a low tail underneath it.
    "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": "@barrel", "color": "#ffffff", "sec": "0.05 + charge*0.10" },
        { "comet": "@barrel", "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 }
      ]
    },

    "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 }
      ]
    },

    // Nothing left to fire: a dry click, and no light at all.
    "dryFire": {
      "children": [
        { "play": "click.wav", "volume": 0.5, "flow": "detached" },
        { "wait": 0.12 }
      ]
    },

    "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 }
      ]
    }
  },

  "rules": [
    { "when": "heat >= 1", "to": "Overheated" }
  ],

  "states": {
    "initial": "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" }
      }
    },

    // Held. Charging ends the moment the trigger comes back up,
    // whether it reached full power or not.
    "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" }
    }
  }
}

Next: 8 · The blaster — segments, a boot sequence, and the finished prop.