Skip to content

6 · The HUD

A screen is a device like the strip is a device. You do not push pixels at it — you declare what is on it, and the engine redraws the whole thing from your numbers, sixty times a second.

Live TRIGGER fires and heats; RELOAD refills. Watch the ammo counter, the heat bar and the reticle colour — none of them is animated, all of them are expressions.

SETUP ▸ Devices ▸ + new device ▸ hud. Set kind to screen.

A device card in the Studio named hud, with kind set to screen, width 320, height 170 and a rotation dropdown reading (build). There is no pin box.
No pin box: a screen is a framebuffer, and the board already knows which panel it is wired to.

Set width 320 and height 170 — the coordinate space you will draw in. Match your panel, or leave them at 0 for the backend default. The rotation dropdown says (build), meaning “whatever the firmware was built for”; change it and set width and height to match, or the drawing and the panel will disagree about which way up they are.

Watch the palette while you do this. The whole Screen group and the Widgets group were dimmed; the moment the screen device exists, fifteen verbs light up. That is the palette telling you what your show can now do.

Scripts ▸ + new script ▸ hudView. On its card, tick autostart.

The hudView script card in the Studio with autostart ticked and fourteen step rows beneath it: a fill, two text rows, a number row with digits and size boxes, a meter row with min, max, x, y, w and h boxes, a circle row with x, y, r and an outline checkbox, four line rows with x0,y0 → x1,y1 boxes, and three more text rows — every one of them with its flow dropdown set to parallel.
Fourteen layers, drawn in the order they are listed. Every row's flow is parallel, so they are layers and not steps.

Three things are doing the work here.

autostart starts the script when the show loads, without any state having to. The HUD is not part of the blaster’s behaviour; it is always on.

Every child’s flow is parallel. They are layers, drawn top to bottom in list order, not steps. Drag a row to a different position (the ⠿ handle on the left of every row) and you change what is drawn over what.

The first layer is a fill. The framebuffer is persistent — it is not cleared for you — so without a black fill at the top of the list, last frame’s reticle stays on screen and the display smears. Clear first, always.

Palette group Verbs Draws
Screen fill the whole canvas one colour
Screen rect circle line pixel polygon polyline shapes, each with an outline checkbox
Screen text a fixed string
Screen number a live var or expression, with dig to zero-pad
Screen image anim a still, or a baked clip
Screen paint a per-pixel expression over the whole canvas
Widgets gauge meter graph a radial dial, a bar, a scrolling plot

Each of those rows carries its own geometry boxes — circle gets x, y, r and an outline tickbox; line gets x0,y0 → x1,y1; meter gets val, a [min..max] pair and x, y, w, h.

The blaster HUD: AMMO 12 in green at top left, an empty HEAT bar at bottom left, a green circular reticle with four tick marks and a centre dot at right, and READY lit green in a row with dimmed EMPTY and VENT.
A ring, four ticks, a dot — and one colour expression shared by all six.

Add a circle: x 215, y 85, r 42, outline ticked. Then in its colour box, instead of picking a swatch, type:

hsv(0.33 - heat*0.33, 0.9, 0.9)

Hue 0.33 is green; 0.33 - heat*0.33 walks it through amber to red as the barrel heats. Paste the same expression into the four line ticks and the centre dot, and the whole sight changes colour together with nothing coordinating it.

text takes a fixed string. number takes a var or an expression and formats it — which is the difference between a label and an instrument.

Add a number with ammo in its value box, dig 2 to zero-pad, size 4, and this in its colour box:

ammo > 0 ? 0x00e0a0 : 0xff3b30

An empty magazine turns the counter red, with no state involved.

A meter does the same job as a bar — val heat, [0..1], and a colour expression of hsv(0.33 - heat*0.33, 1, 1).

The HUD mid-fight: AMMO showing a reduced count, the HEAT bar about half full and amber, the reticle amber, READY still lit.
Mid-fight. Everything moved, and not one of these is an animation.

Because text cannot change its string, a line that has to change is several text rows, each lit by its own colour expression:

Row value x colour expression
1 READY 150 venting == 0 && ammo > 0 ? 0x00e0a0 : 0x1d2a24
2 EMPTY 200 ammo <= 0 ? 0xff3b30 : 0x2a1d1d
3 VENT 252 venting ? 0xffb000 : 0x2a2418
The HUD while venting: the HEAT bar full and amber, the reticle amber, VENT lit in amber with READY and EMPTY dimmed.
Venting. VENT stays lit for the whole cycle, because it reads the state and not the temperature.

The first version of that row read heat >= 1 for VENT. It was wrong in a way worth showing: the vent script starts pulling heat down immediately, so heat drops below 1 on the very next frame, VENT flickers once, and the HUD goes back to claiming READY while the barrel is still venting.

The fix is a var the state machine owns. Add venting in Variables, then on the Overheated card:

  • on enter — set venting = 1, then run vent
  • on exit — set venting = 0

A var is how a state tells the display what it is doing. Any time a readout infers the mode from a physical quantity, it will eventually infer it wrong.

step-06-the-hud.json 162 lines
{
  // ===========================================================
  //  BLASTER · STEP 6 — The HUD
  //  A screen is a device like any other. You do not blit
  //  images at it; you declare what is drawn on it, and the
  //  engine redraws every frame from the numbers you track.
  // ===========================================================
  "schemaVersion": 1,
  "name": "Blaster 06 - The HUD",
  "board": "t-display-s3",

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

  "devices": {
    "barrel": { "kind": "ledstrip", "count": 8, "pin": 16, "order": "GRB", "brightness": 160 },
    // No pin: a screen is a framebuffer, and the board knows
    // which panel it is wired to.
    "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 is one script, started once and left running. Every
    // child is `parallel`: they are LAYERS drawn in order, not
    // steps, so all of them are on screen at the same time.
    //
    // It starts with a full-canvas fill because the framebuffer
    // is persistent — without it, last frame's reticle smears.
    "hudView": {
      "autostart": true,
      "children": [
        { "fill": "@hud", "color": "#000000", "flow": "parallel" },

        // ---- left column: ammo ----------------------------------
        { "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" },

        // ---- left column: heat ----------------------------------
        { "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 ----------------------------------------
        // Ring, four ticks, centre dot. The whole thing is one
        // colour expression, so it goes green -> red with heat and
        // the reader can see WHY by reading one line.
        { "circle": "@hud", "x": 215, "y": 85, "r": 42, "outline": true, "flow": "parallel",
          "color": "hsv(0.33 - heat*0.33, 0.9, 0.9)" },
        { "circle": "@hud", "x": 215, "y": 85, "r": 3, "flow": "parallel",
          "color": "hsv(0.33 - heat*0.33, 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)" },

        // ---- annunciator row ------------------------------------
        // `text` draws a FIXED string, so a line that has to change
        // is several lines, each lit by its own colour expression.
        //
        // Give every word its OWN SLOT and dim the inactive ones,
        // rather than stacking all three in one place and blacking
        // two of them out. A glyph is drawn wherever its bits are
        // set — including when that colour is black — so a black
        // word on top punches holes in the bright one underneath.
        { "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))" }
      ]
    },

    "shot": {
      "children": [
        { "tone": 720, "wave": "square", "seconds": 0.09, "volume": 0.35, "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 }
      ]
    },

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

  "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" },
        "reload.tapped":  { "to": "Reloading", "when": "ammo < 12" }
      }
    },

    "Firing": {
      "enter": [
        { "set": "heat", "to": "min(heat + 0.17, 1)" },
        { "set": "ammo", "to": "max(ammo - 1, 0)" },
        { "run": "shot" }
      ],
      "on": { "shot.done": "Ready" }
    },

    "Reloading": {
      "enter": [ { "run": "reloadSeq" } ],
      "on": { "reloadSeq.done": "Ready" }
    },

    "Overheated": {
      // A var is how a state tells the display what it is doing.
      // The annunciator used to read `heat >= 1`, which is dark
      // again a frame after the vent starts pulling heat down —
      // so VENT flickered once and the HUD went back to claiming
      // READY while the prop was still venting.
      "enter": [ { "set": "venting", "to": 1 }, { "run": "vent" } ],
      "exit":  [ { "set": "venting", "to": 0 } ],
      "on": { "vent.done": "Ready" }
    }
  }
}

Next: 7 · Sound — and a charge-up whose pitch is an expression.