BLIT386BLIT386

Example: Gamepad Input

A tiny hover-pod playground for gamepads: connect status, analog sticks, triggers, and button masks.

Written for blit386 1.8.0. The rest of blit386.dev documents the newest engine, so an API you find there may be missing from a game pinned to 1.8.0.

Live version: https://demos.blit386.dev/gamepad-input. This is the demo-site source, not a standalone file: it imports and calls helpers from ./shared/ (themed panels, on-screen D-pad, post-process fallbacks), which are not part of blit386 and are not on npm. To run it as is, keep those files from https://github.com/blit386/blit386/tree/main/packages/demos/src/shared at the same relative path. To adapt it, remove or replace the helper imports and every call to them.

gamepad-input.js
/*

Gamepad Input: analog sticks, triggers, and face-button masks.
@description A tiny hover-pod playground for gamepads: connect status, analog sticks, triggers, and button masks.
@ogScale integer

Part of the BLIT386 demo series.

Prerequisites:
  - Basics: https://demos.blit386.dev/basics
  - Keyboard Input: https://demos.blit386.dev/keyboard-input

Live version: https://demos.blit386.dev/gamepad-input

Guides:
  - Input: https://blit386.dev/docs/guides/input#keyboard
  - Input: https://blit386.dev/docs/guides/input#frame-timing-semantics
  - Game Loop: https://blit386.dev/docs/guides/game-loop#smoothing-motion-between-updates
  - Overlay: https://blit386.dev/docs/guides/overlay#optional-bands

Reference:
  - Game Loop: https://blit386.dev/docs/api/game-loop#interpolating-render-state-with-renderalpha
  - Overlay: https://blit386.dev/docs/api/overlay#event-tags

---

WHAT YOU WILL SEE
-----------------

A title strip across the top and two columns under it, both ending on the same line.

On the left, a "Controls" panel lists what each stick, trigger, and button does. Below it,
a dark arena with a border fills the rest of the column: inside it a small square "hover
pod" with a white outline and a small crosshair. Until a gamepad is connected, a warm
prompt sits near the top of the arena (on a touch screen it says "This demo needs a
gamepad" instead).

On the right, a status panel from the shared UI kit, titled with how many gamepads the
browser reports ("Gamepads: 0"): a pip for player one's connection, pips for A, B, the
(A|B) mask, any button, L2 and R2, then the raw left and right stick numbers (LX, LY, RX,
RY) and a "Throttle" meter that fills with trigger pressure. Under it, a "Pod" panel
shows the pod's current color, whether the trail is on, and a "Boost" meter that empties
as an R2 boost runs out. When the gamepad connects, a "Gamepad connected" tag lands on the
engine overlay's timing chart.

WHAT YOU WILL LEARN
-------------------

- BT.isGamepadConnected(player) and BT.gamepadCount for connection status, with a simple
  rising-edge check to react once when a pad appears.
- BT.getAxis(axis, player) with BT.AXIS_LEFT_X / AXIS_LEFT_Y / AXIS_RIGHT_X / AXIS_RIGHT_Y
  (-1 to +1) and BT.AXIS_TRIGGER_L / AXIS_TRIGGER_R (0 to 1), dead-zone filtered by the
  engine.
- Button masks: BT.isDown(BT.BTN_A | BT.BTN_B, player) is true when either button is
  held.
- The triggers as digital buttons: BT.isDown(BT.BTN_L2, player) and
  BT.isPressed(BT.BTN_R2, player) turn on at half pull.
- BT.isAnyButtonDown(player) for "is anything held" checks.
- Smoothing motion with BT.renderAlpha and Vector2i.lerp between the previous and current
  tick positions.
- BT.PLAYER_ONE as the player index, and BT.assignTag for the timing chart.

HOW THE POD IS STEERED
----------------------

- The left stick moves the pod around the arena; the right stick moves the crosshair.
- Trigger pressure grows the pod from 10 up to 20 pixels (a little "throttle" feeling).
  Whichever trigger reads higher wins.
- A cycles the pod through three colors, B toggles a breadcrumb trail, Start recenters
  the pod and crosshair.
- R2, read as a button, gives the pod a 20-tick speed boost at double speed.

Face buttons for player 0 merge keyboard and gamepad, so the default keyboard map also
works for A (Space or B), B (N), and Start (5). The sticks, triggers, L2, and R2 are
gamepad only.

TRY THIS
--------

- Plug in a gamepad and press any button so the browser wakes it up.
- Move the left stick to fly the pod; move the right stick to drag the crosshair. The
  default dead zone is large, so push the stick most of the way before anything moves.
- Squeeze either trigger and watch the pod grow and the Throttle meter fill.
- Hold A and B together and watch the "(A|B) mask" pip in the panel light up.
- Pull R2 past half way while moving the left stick to feel the boost; the "R2 held" pip
  lights once R2 passes half pull.
- Press B to turn the trail on, fly around, and press B again to clear it.
- Press the backquote key (`) to open the engine overlay and look for the "Gamepad
  connected" tag on the timing chart.

*/

import { bootstrap, BT, Color32, Rect2i, Vector2i } from 'blit386';

import { applyTheme, ui, UI_ANCHORS } from './shared/ui.js';

/** @typedef {import('blit386').Palette} Palette */

// Screen size in display pixels. 288 is tall enough for every row of the status panel.
const DISPLAY_W = 320;
const DISPLAY_H = 288;

// Every gamepad read in this demo is for the first player slot (index 0).
const PLAYER = BT.PLAYER_ONE;

// Scene palette indices. Slot 0 stays transparent; toy colors start at 1. The shared UI
// kit's applyTheme() adds its own twelve colors way up at slots 240+, so nothing
// collides.
const C_POD_OUTLINE = 1; // pod outline white; doubles as the timing-chart render bar
const C_ARENA = 2; // filled interior of the steering arena
const C_ARENA_BORDER = 3; // arena border rectangle
const C_CHART_DIM = 4; // timing-chart update bar (soft gray)
const C_CHART_TAG = 5; // timing-chart milestone tag color (amber)
const C_POD_A = 6; // first pod color (cycles with A)
const C_POD_B = 7; // second pod color
const C_POD_C = 8; // third pod color
const C_TRAIL = 9; // white with a lower alpha value, for the trail pixels
const C_AIM = 10; // right-stick crosshair color

// Layout on a 4-pixel grid: 4 pixels from the screen edges, 4 between panels. Two
// columns under the 22-pixel title strip, 204 and 104 pixels wide
// (4 + 204 + 4 + 104 + 4 = 320).
//
//   - Right column: the Gamepad panel (22 title + 7 pip rows + 4 axis rows + a 23-pixel
//     meter + 11 bottom = 166 pixels), then the Pod panel (22 + 2 rows + a meter + 11 =
//     76 pixels). Both columns end at y = 272, above the bottom-left 17x13 pixels the
//     engine overlay keeps for its toggle hint.
//   - Left column: the Controls panel (22 + 4 rows + 11 = 73 pixels), then the arena,
//     which stretches down to the same bottom line as the right column.
const EDGE = 4;
const GAP = 4;
const TOP_Y = 26;
const LEFT_W = 204;
const RIGHT_W = 104;
const RIGHT_X = DISPLAY_W - EDGE - RIGHT_W;
const GAMEPAD_PANEL_H = 22 + 7 * 10 + 4 * 10 + 23 + 11;
const POD_PANEL_Y = TOP_Y + GAMEPAD_PANEL_H + GAP;
const POD_PANEL_H = 22 + 2 * 10 + 23 + 11;
const COLUMN_BOTTOM = POD_PANEL_Y + POD_PANEL_H;
const CONTROLS_PANEL_H = 22 + 4 * 10 + 11;

// Meters in the right column fill the panel's content width (the panel minus 9 pixels of
// padding on each side).
const METER_W = RIGHT_W - 18;

// The play arena rectangle, in display pixels: under the Controls panel, down to the
// bottom of the right column.
const ARENA_X = EDGE;
const ARENA_Y = TOP_Y + CONTROLS_PANEL_H + GAP;
const ARENA_W = LEFT_W;
const ARENA_H = COLUMN_BOTTOM - ARENA_Y;

// Names of the three pod colors, in the same order A cycles through them.
const POD_COLOR_NAMES = ['green', 'red', 'blue'];

// Pod size in pixels: 10 at rest, up to 10 more at full trigger pull. POD_SPEED is how
// many pixels per tick a fully pushed stick moves it.
const POD_BASE_SIZE = 10;
const POD_MAX_EXTRA_SIZE = 10;
const POD_SPEED = 3;

// An R2 press gives the pod a short speed burst: BOOST_TICKS update ticks at BOOST_FACTOR
// times the normal speed.
const BOOST_TICKS = 20;
const BOOST_FACTOR = 2;

// Pixels per tick for the right-stick crosshair, and how many trail points we keep.
const AIM_SPEED = 4;
const TRAIL_MAX = 28;

// Formats a stick axis value (-1 to +1) as a fixed-width string like '+0.50' or '-0.32'.
// Writing the '+' out keeps every row the same width, so the numbers in the panel do not
// jiggle left and right as the sign flips.
function formatAxis(value) {
    // toFixed(2) rounds to two decimal places and always prints both (0.5 -> '0.50').
    return (value >= 0 ? '+' : '') + value.toFixed(2);
}

// Tiny gamepad playground that visualizes sticks, triggers, and buttons.
class Demo {
    /** @type {Palette | null} */
    palette = null;

    // Palette slots of the shared UI theme colors, filled by applyTheme() in init().
    /** @type {ReturnType<typeof applyTheme> | null} */
    theme = null;

    // Pod position in integer display pixels.
    podPos = new Vector2i(Math.floor(DISPLAY_W / 2), Math.floor(DISPLAY_H / 2));

    // Small cursor moved by the right stick so both sticks have visible jobs.
    aimPos = new Vector2i(Math.floor(DISPLAY_W / 2) + 28, Math.floor(DISPLAY_H / 2));

    // Pod and aim position at the START of the most recent update() tick, before this
    // tick's stick input moved them. render() blends between these and podPos/aimPos
    // using BT.renderAlpha so the pod and aim cursor glide smoothly between ticks instead
    // of jumping - see the Game Loop guide linked in the header. Start equal to
    // podPos/aimPos so the very first frame does not blend in from a stale position.
    prevPodPos = this.podPos;

    prevAimPos = this.aimPos;

    // Trail points for the optional motion history, newest last.
    /** @type {Vector2i[]} */
    trail = [];

    // Whether B has switched the trail on.
    trailEnabled = false;

    // Cycles through three pod colors when A is pressed: 0, 1, 2, then back to 0.
    podColorIndex = 0;

    // Update ticks of boost left. R2 (the right trigger as a DIGITAL button) starts it.
    boostTicks = 0;

    // Whether a gamepad was connected at the end of the previous tick, for the
    // rising-edge check in update().
    wasConnected = false;

    // Screen size, timing chart colors, and the keyboard scroll guard.
    configure() {
        return {
            displaySize: new Vector2i(DISPLAY_W, DISPLAY_H),

            // The default keyboard layout maps Space to the A button this demo reads -
            // keep the page from scrolling when it is pressed.
            isCapturingKeyboardScroll: true,

            isOverlayTimingChartEnabled: true,
            overlayTimingChartStyle: {
                updateBarPaletteIndex: C_CHART_DIM,
                renderBarPaletteIndex: C_POD_OUTLINE,
                tagPaletteIndex: C_CHART_TAG,
            },
        };
    }

    // Builds a small custom palette, installs the shared UI theme, and starts centered.
    async init() {
        // Build a small custom palette before any drawing happens. Think of each slot as
        // a numbered paint can the engine looks up while rendering.
        this.palette = BT.paletteCreate(256);

        this.palette.set(C_POD_OUTLINE, new Color32(245, 248, 255));
        this.palette.set(C_ARENA, new Color32(24, 30, 47));
        this.palette.set(C_ARENA_BORDER, new Color32(68, 80, 108));
        this.palette.set(C_CHART_DIM, new Color32(130, 144, 173));
        this.palette.set(C_CHART_TAG, new Color32(255, 190, 95));
        this.palette.set(C_POD_A, new Color32(120, 255, 170));
        this.palette.set(C_POD_B, new Color32(255, 130, 140));
        this.palette.set(C_POD_C, new Color32(120, 185, 255));
        this.palette.set(C_TRAIL, new Color32(255, 255, 255, 120));
        this.palette.set(C_AIM, new Color32(255, 220, 130));

        // The shared UI kit needs its theme colors in the palette before any ui.* call
        // can draw. applyTheme() writes them into high slots (240 and up), far away from
        // the scene slots above, and hands back a map like { bg: 240, text: 244, ... } so
        // this demo can reuse theme colors for its own drawing (we clear with bg).
        this.theme = applyTheme(this.palette);

        // Tell the engine to use this palette for every draw call from now on.
        BT.paletteSet(this.palette);

        // Place the pod and aim cursor in the middle of the arena.
        this.resetPod();

        return true;
    }

    // Reads gamepad input and updates the toy state.
    update() {
        // Let the UI kit do its once-per-tick housekeeping first. This demo has no kit
        // buttons or key bindings, but ui.tick() also tracks whether the screen has ever
        // been touched - that is what feeds the ui.hasTouch() hint drawn in render().
        ui.tick();

        // Ask the engine whether a gamepad is currently plugged in for this player slot.
        const connected = BT.isGamepadConnected(PLAYER);

        // "Edge detection": we only want to react the moment the gamepad is first
        // connected, not every single tick it stays connected. So we compare the current
        // state to what it was last tick (stored in wasConnected). If it just became true
        // (false -> true), that is the "rising edge" - the instant of connection.
        if (connected && !this.wasConnected) {
            // Label this moment on the overlay timing chart so you can see exactly which
            // frame the gamepad was detected.
            BT.assignTag('Gamepad connected');
        }

        // Save this tick's connection state so the next tick can compare against it.
        this.wasConnected = connected;

        // Button edges. For player 0, BT.isPressed merges the keyboard map and the
        // gamepad: the keyboard half clears once per update tick, so update() is where
        // it must be read. The gamepad half rolls over once per rendered frame instead,
        // so on a display faster than targetFPS a gamepad press can land in a frame that
        // runs no update and be missed here - see the input guide's frame-timing section.

        // A press cycles the pod color once per physical press.
        if (BT.isPressed(BT.BTN_A, PLAYER)) {
            this.podColorIndex = (this.podColorIndex + 1) % 3;
        }

        // B press toggles trail drawing.
        if (BT.isPressed(BT.BTN_B, PLAYER)) {
            this.trailEnabled = !this.trailEnabled;

            // Clearing keeps the trail from "teleporting" across toggles.
            if (!this.trailEnabled) {
                this.trail = [];
            }
        }

        // Start recenters the toy instantly.
        if (BT.isPressed(BT.BTN_START, PLAYER)) {
            this.resetPod();
        }

        // R2 is the right trigger read as a button: BT.isPressed fires once per pull past
        // half way, like any other button. (BT.getAxis below still gives the analog
        // pull.)
        if (BT.isPressed(BT.BTN_R2, PLAYER)) {
            this.boostTicks = BOOST_TICKS;
        }

        // Axes are dead-zone filtered by the engine (see renderArena for the details).
        const moveX = BT.getAxis(BT.AXIS_LEFT_X, PLAYER);
        const moveY = BT.getAxis(BT.AXIS_LEFT_Y, PLAYER);
        const aimX = BT.getAxis(BT.AXIS_RIGHT_X, PLAYER);
        const aimY = BT.getAxis(BT.AXIS_RIGHT_Y, PLAYER);
        const throttle = Math.max(BT.getAxis(BT.AXIS_TRIGGER_L, PLAYER), BT.getAxis(BT.AXIS_TRIGGER_R, PLAYER));

        // Remember where the pod and aim cursor were before this tick moves them, so
        // render() can draw a smooth in-between position instead of a pop.
        this.prevPodPos = this.podPos;
        this.prevAimPos = this.aimPos;

        // Convert analog float values into integer pixel steps. While boosted, the pod
        // moves BOOST_FACTOR times faster; each tick uses one boost tick up.
        const speed = this.boostTicks > 0 ? POD_SPEED * BOOST_FACTOR : POD_SPEED;

        if (this.boostTicks > 0) {
            this.boostTicks -= 1;
        }

        this.podPos = this.podPos.add(new Vector2i(Math.round(moveX * speed), Math.round(moveY * speed)));
        this.aimPos = this.aimPos.add(new Vector2i(Math.round(aimX * AIM_SPEED), Math.round(aimY * AIM_SPEED)));

        // Keep both inside the arena. The pod's half size depends on the trigger pull,
        // the crosshair is always 4 pixels from center to tip.
        const podHalf = this.currentPodHalfSize(throttle);

        this.podPos = this.clampToArena(this.podPos, podHalf);
        this.aimPos = this.clampToArena(this.aimPos, 4);

        // With the trail on, remember this tick's position and drop the oldest point once
        // the list is full.
        if (this.trailEnabled) {
            this.trail.push(this.podPos);

            if (this.trail.length > TRAIL_MAX) {
                this.trail.shift();
            }
        }
    }

    // Draws the toy arena and the UI kit groups: title bar, hints, and the status panel.
    render() {
        // Erase last frame with the shared theme background, then redraw from scratch.
        BT.clear(this.theme.bg);

        // The full-width title strip along the top edge.
        ui.begin(UI_ANCHORS.TOP_BAR);
        ui.panel('Gamepad Input - sticks, triggers, button masks');
        ui.end();

        this.renderHints();
        this.renderArena();
        this.renderStatusPanel();
        this.renderPodPanel();

        // When no gamepad is plugged in yet, show a friendly prompt inside the arena.
        if (!BT.isGamepadConnected(PLAYER)) {
            this.renderEmptyState();
        }
    }

    // Returns the palette slot of the current pod color.
    currentPodColor() {
        if (this.podColorIndex === 0) {
            return C_POD_A;
        }

        if (this.podColorIndex === 1) {
            return C_POD_B;
        }

        return C_POD_C;
    }

    // Converts trigger pressure (0 to 1) into the pod's size in whole pixels.
    currentPodSize(throttle) {
        return POD_BASE_SIZE + Math.round(throttle * POD_MAX_EXTRA_SIZE);
    }

    // Returns half of the rendered pod size, rounded down to whole pixels.
    currentPodHalfSize(throttle) {
        return Math.floor(this.currentPodSize(throttle) / 2);
    }

    // Keeps a point inside the play area so that a square of the given `radius` around it
    // never pokes through the arena border. Returns the clamped position.
    clampToArena(pos, radius) {
        // The center may come no closer to a wall than its own radius, so the pod's edge
        // stops right at the arena border instead of poking through it.
        const minX = ARENA_X + radius;
        const maxX = ARENA_X + ARENA_W - 1 - radius;
        const minY = ARENA_Y + radius;
        const maxY = ARENA_Y + ARENA_H - 1 - radius;

        // Math.min picks the smaller of two numbers, Math.max the larger; chaining them
        // "clamps" a value so it can never leave the min..max range.
        const clampedX = Math.max(minX, Math.min(maxX, pos.x));
        const clampedY = Math.max(minY, Math.min(maxY, pos.y));

        return new Vector2i(clampedX, clampedY);
    }

    // Puts the pod in the arena center and the aim cursor 28 pixels to its right.
    resetPod() {
        this.podPos = new Vector2i(ARENA_X + Math.floor(ARENA_W / 2), ARENA_Y + Math.floor(ARENA_H / 2));
        this.aimPos = this.podPos.add(new Vector2i(28, 0));

        // Snap prevPodPos/prevAimPos to match, so render() does not blend in from
        // wherever the pod was before the reset (Start button press).
        this.prevPodPos = this.podPos;
        this.prevAimPos = this.aimPos;

        this.trail = [];
        this.boostTicks = 0;
    }

    // The Controls panel at the top of the left column: what each stick, trigger, and
    // button does, as four dim lines.
    renderHints() {
        ui.begin(UI_ANCHORS.TOP_LEFT, { x: EDGE, y: TOP_Y, width: LEFT_W });
        ui.panel('Controls');
        ui.label('L stick moves, R stick aims', { color: 'dim' });
        ui.label('Triggers grow the pod', { color: 'dim' });
        ui.label('A color, B trail, R2 boost', { color: 'dim' });
        ui.label('Start recenters', { color: 'dim' });
        ui.end();
    }

    // Draws the arena border, the optional trail, the pod, and the aim cursor.
    renderArena() {
        // Play area: a filled rectangle with a border so the pod has visible walls.
        BT.drawRectFill(new Rect2i(ARENA_X, ARENA_Y, ARENA_W, ARENA_H), C_ARENA);
        BT.drawRect(new Rect2i(ARENA_X, ARENA_Y, ARENA_W, ARENA_H), C_ARENA_BORDER);

        // Optional breadcrumb trail: one pixel per past pod position when B toggled it
        // on.
        if (this.trailEnabled) {
            for (let i = 0; i < this.trail.length; i++) {
                const p = this.trail[i];

                BT.drawPixel(p, C_TRAIL);
            }
        }

        // BT.getAxis returns a float from -1 to +1 for sticks, 0 to +1 for triggers. The
        // engine applies a "dead zone" first: a stick axis below 0.75 of full travel (the
        // default) reads as 0, and the rest is stretched back out to the full range, so
        // the pod does not drift when you let go of the stick. We read BOTH triggers and
        // keep whichever is squeezed harder (Math.max).
        const throttle = Math.max(BT.getAxis(BT.AXIS_TRIGGER_L, PLAYER), BT.getAxis(BT.AXIS_TRIGGER_R, PLAYER));
        const size = this.currentPodSize(throttle);
        const half = this.currentPodHalfSize(throttle);

        // Blend each entity's previous and current tick position by BT.renderAlpha so the
        // pod and aim cursor glide smoothly across render frames instead of snapping to a
        // new spot only once per tick (see the prevPodPos/prevAimPos comment above).
        const pod = Vector2i.lerp(this.prevPodPos, this.podPos, BT.renderAlpha);
        const aim = Vector2i.lerp(this.prevAimPos, this.aimPos, BT.renderAlpha);

        // Draw the pod as a square centered on its position; the white outline makes it
        // pop against the arena.
        BT.drawRectFill(new Rect2i(pod.x - half, pod.y - half, size, size), this.currentPodColor());
        BT.drawRect(new Rect2i(pod.x - half, pod.y - half, size, size), C_POD_OUTLINE);

        // Right-stick aim cursor: a small crosshair so both sticks have visible jobs.
        BT.drawLine(new Vector2i(aim.x - 4, aim.y), new Vector2i(aim.x + 4, aim.y), C_AIM);
        BT.drawLine(new Vector2i(aim.x, aim.y - 4), new Vector2i(aim.x, aim.y + 4), C_AIM);
    }

    // The right-hand status panel: connection info, live button pips, raw stick axis
    // numbers, and a trigger pressure meter. Everything read here is HELD state or an
    // axis value, both safe to read in render(). Button EDGES (BT.isPressed - "did it go
    // down just now?") are handled in update() above.
    renderStatusPanel() {
        // Bitmask OR: BT.BTN_A | BT.BTN_B builds one mask. isDown returns true when
        // EITHER button is held - handy for "press any of these" checks in games. The two
        // single-button pips above it let you watch the OR happen live.
        const aHeld = BT.isDown(BT.BTN_A, PLAYER);
        const bHeld = BT.isDown(BT.BTN_B, PLAYER);
        const maskHeld = BT.isDown(BT.BTN_A | BT.BTN_B, PLAYER);

        // Raw axis values for the readout rows - the same calls the movement code uses.
        const moveX = BT.getAxis(BT.AXIS_LEFT_X, PLAYER);
        const moveY = BT.getAxis(BT.AXIS_LEFT_Y, PLAYER);
        const aimX = BT.getAxis(BT.AXIS_RIGHT_X, PLAYER);
        const aimY = BT.getAxis(BT.AXIS_RIGHT_Y, PLAYER);
        const throttle = Math.max(BT.getAxis(BT.AXIS_TRIGGER_L, PLAYER), BT.getAxis(BT.AXIS_TRIGGER_R, PLAYER));

        // The panel title says how many gamepads the browser reports; the first pip says
        // whether player one has one.
        ui.begin(UI_ANCHORS.TOP_LEFT, { x: RIGHT_X, y: TOP_Y, width: RIGHT_W });
        ui.panel(`Gamepads: ${BT.gamepadCount}`);
        ui.pip('P1 connected', BT.isGamepadConnected(PLAYER));

        // Pips light up while a button is physically held down right now.
        ui.pip('A held', aHeld);
        ui.pip('B held', bHeld);
        ui.pip('(A|B) mask', maskHeld);

        // Lit while ANY gamepad button is held (D-pad, face, shoulders, Start, Select -
        // not sticks or triggers). BT.isAnyButtonPressed(player) is the matching edge for
        // "press any button".
        ui.pip('Any button', BT.isAnyButtonDown(PLAYER));

        // The triggers as DIGITAL buttons: held once pulled past half way. The Throttle
        // meter below shows the same triggers as an ANALOG 0 to 1 pull.
        ui.pip('L2 held', BT.isDown(BT.BTN_L2, PLAYER));
        ui.pip('R2 held', BT.isDown(BT.BTN_R2, PLAYER));

        // LX/LY are the left stick, RX/RY the right stick, each from -1.00 to +1.00.
        ui.kv('LX', formatAxis(moveX));
        ui.kv('LY', formatAxis(moveY));
        ui.kv('RX', formatAxis(aimX));
        ui.kv('RY', formatAxis(aimY));

        // The meter fills left-to-right with trigger pressure (0 = released, 1 = fully
        // squeezed) - the same value that grows the pod.
        ui.meter('Throttle', throttle, { width: METER_W });
        ui.end();
    }

    // The Pod panel under the status panel: what A, B, and R2 have done to the pod. These
    // work from the keyboard too (Space or B for A, N for B), so this panel reacts even
    // without a gamepad - except the boost, which needs R2.
    renderPodPanel() {
        ui.begin(UI_ANCHORS.TOP_LEFT, { x: RIGHT_X, y: POD_PANEL_Y, width: RIGHT_W });
        ui.panel('Pod');
        ui.kv('Color', POD_COLOR_NAMES[this.podColorIndex]);
        ui.kv('Trail', this.trailEnabled ? 'on' : 'off');

        // The boost meter is full the tick R2 starts a boost and empties as boostTicks
        // counts down to 0.
        ui.meter('Boost', this.boostTicks / BOOST_TICKS, { width: METER_W });
        ui.end();
    }

    // The "no gamepad yet" prompt, shown inside the empty arena as warm kit labels in a
    // borderless group. Pinned near the top of the arena so the idle pod and aim
    // crosshair (arena center) do not sit on the text.
    renderEmptyState() {
        ui.begin(UI_ANCHORS.TOP_LEFT, { x: ARENA_X + 6, y: ARENA_Y + 6 });

        // ui.hasTouch() turns true after the first touch contact and stays true. Touch
        // devices rarely have a gamepad attached, so tell those visitors plainly that
        // this page cannot work for them.
        if (ui.hasTouch()) {
            ui.label('This demo needs a gamepad.', { color: 'warm' });
        } else {
            ui.label('Connect a gamepad and press', { color: 'warm' });
            ui.label('any button to wake it.', { color: 'warm' });
        }

        ui.end();
    }
}

bootstrap(Demo);