BLIT386BLIT386

Example: Random Basics

Five scenes for the BT.random generators: shuffling, weighted drops, scatter, flips, and walkers.

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/random-basics. 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.

random-basics.js
/*

Random Basics: the shuffling, weighting, and scattering tools built into the engine.
@description Five scenes for the BT.random generators: shuffling, weighted drops, scatter, flips, and walkers.

Part of the BLIT386 demo series.

Prerequisites:
  - Basics: https://demos.blit386.dev/basics
  - Primitives: https://demos.blit386.dev/primitives
  - Colors: https://demos.blit386.dev/colors

Live version: https://demos.blit386.dev/random-basics

Guides:
  - Random: https://blit386.dev/docs/guides/random#deterministic-by-default

Reference:
  - Random: https://blit386.dev/docs/api/random#generators

---

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

Five small scenes, one at a time, with the engine overlay turned off so they get the whole
screen. Every scene has the same bottom row: the "Scene 1/5" picker on the left, with Prev
and Next buttons marked with arrows (press 1-5 to jump straight to a scene), and that
scene's own panel on the right. Each scene shows one way of asking the engine for a
random answer:

  1. Shuffle - two rows of eight colored cards: the list itself, and a shuffled copy.
  2. Weighted - a treasure chest dropping gems in four colors, and a bar chart of every
     drop so far, with a white line on each bar at the share the code asked for.
  3. Gaussian - small squares landing near a target of four nested rings, not anywhere.
     The panel spells out the exact call each one makes.
  4. Sign - a dot walking along a line, stepping left or right on a coin flip, with the
     spots it stood on before trailing down underneath.
  5. Directions - two bugs wandering their own squares, one allowed 4 ways, the other 8.

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

- BT.random.shuffle(list) hands back a new mixed-up copy and leaves your list alone.
- BT.random.shuffleInPlace(list) mixes up the list you gave it, so the old order is gone.
- BT.random.weighted(items, weights) lets you say "this prize should show up 70 times as
  often as that one".
- BT.random.gaussian(mean, spread) clumps results near a middle value instead of spreading
  them evenly; BT.random.float(min, max) is the flat version, shown next to it to compare.
- BT.random.sign() answers -1 or 1, which is perfect for "which way?" questions.
- BT.random.direction4() and BT.random.direction8() hand back a ready-made step as a
  Vector2i.
- Counting update ticks and using the % remainder to act every few ticks instead of every
  single one.

A NOTE ON WHAT IS NOT HERE
--------------------------

The everyday tools - int(), float(), and pick() - already appear all over the other demos,
so this one covers the ones you have not met yet. The Camera demo uses int() and
pointInRange() to scatter buildings: https://demos.blit386.dev/camera

TRY THIS
--------

- Press 1-5, or tap the Prev and Next buttons, to change scene.
- Scene 1: press C ("shuffle() a copy") and only the bottom row changes. Press P
  ("shuffleInPlace()") and the top row changes too - the old order is gone for good.
- Scene 2: wait a while, then compare each bar with its white line, and the "Got" row
  with the 70 / 20 / 9 / 1 the code asked for.
- Scene 3: press G or tap "Use gaussian()" to switch to float(). The green clump becomes
  an evenly filled red square. Drag the "Spread" slider (4 to 40) to tighten or widen it,
  and watch the call under it change.
- Scene 4: watch the "Coin flips" tally stay close to even, but never exactly even, and
  the trail under the line wander off to one side and back.
- Scene 5: compare the blocky blue trail (4 ways) with the rounder orange one (8 ways).

*/

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

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

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

// This demo runs at the engine's default screen size of 320x240 "game pixels", which is
// what every position and size below is measured in.

// The five scenes, in the order the number keys select them.
const MODE_SHUFFLE = 0;
const MODE_WEIGHTED = 1;
const MODE_GAUSSIAN = 2;
const MODE_SIGN = 3;
const MODE_DIRECTIONS = 4;

// Names shown in the mode panel. The array position matches the MODE_* numbers above.
const MODE_NAMES = ['Shuffle', 'Weighted', 'Gaussian', 'Sign', 'Directions'];

// Color slots. The shared UI kit owns slots 240-251, so scene colors stay well below
// that.
const C_BG = 1; // dark background behind every scene
const C_INK = 2; // bright text and outlines
const C_DIM = 3; // faded lines: guides, grids, and old trails
const C_TARGET = 4; // the target rings in the Gaussian scene
const C_SHOT = 5; // arrows that used gaussian()
const C_SHOT_FLAT = 6; // arrows that used float() instead, for comparison
const C_BUG_A = 7; // the 4-direction bug
const C_BUG_B = 8; // the 8-direction bug

// The eight cards in the shuffle scene each get their own color, in slots 10-17.
const C_CARD_BASE = 10;
const CARD_COUNT = 8;

// Card size, and how far apart their left edges sit. Eight cards at 34 pixels apart span
// 264 pixels, so starting 28 pixels in centers the row on the 320-pixel screen.
const CARD_W = 26;
const CARD_H = 34;
const CARD_STRIDE = 34;
const CARD_LEFT = 28;

// Top edges of the two card rows; each row's caption sits just above it.
const CARD_ROW_TOP_Y = 20;
const CARD_ROW_BOTTOM_Y = 74;

// The four treasure tiers each get their own color, in slots 20-23.
const C_TIER_BASE = 20;

// Treasure tiers, rarest last. `TIER_INDEXES` is what we actually hand to weighted():
// asking for the index rather than the name means the answer can be used to look up both
// the label and the color without searching the array afterward.
const TIER_NAMES = ['Common', 'Uncommon', 'Rare', 'Legendary'];
const TIER_INDEXES = [0, 1, 2, 3];
const TIER_WEIGHTS = [70, 20, 9, 1];

// How many update ticks pass between automatic treasure drops.
const DROP_EVERY_TICKS = 8;

// The chest, where gems start falling from it, and the line where they disappear.
const CHEST_RECT = new Rect2i(68, 20, 40, 16);
const DROP_START_Y = 38;
const DROP_FLOOR_Y = 112;

// The bar chart of drops so far: one bar per tier, as tall as that tier's share of all
// drops (a 100% share would be BAR_MAX_H tall), with a faint line at the share the code
// asked for.
const BAR_W = 20;
const BAR_GAP = 12;
const BAR_LEFT = 180;
const BAR_BOTTOM_Y = 100;
const BAR_MAX_H = 80;

// How many arrows stay on screen in the Gaussian scene before the oldest is reused.
const SHOT_COUNT = 120;

// How many update ticks pass between arrows.
const SHOT_EVERY_TICKS = 3;

// Where the target sits, and how wide its rings are.
const TARGET_CENTER = new Vector2i(160, 62);
const TARGET_RINGS = [44, 33, 22, 11];

// The Sign scene walks a dot along this line, and draws where it stood on each of its
// last WALK_HISTORY steps underneath, newest on top, WALK_HISTORY_GAP pixels apart.
const WALK_Y = 30;
const WALK_LEFT = 40;
const WALK_RIGHT = 280;
const WALK_HISTORY = 36;
const WALK_HISTORY_GAP = 2;

// How many update ticks pass between walker steps and bug steps.
const STEP_EVERY_TICKS = 6;

// The Directions scene gives each bug its own square to wander, and remembers this many
// of its recent positions as a trail.
const BUG_AREA_A = new Rect2i(24, 20, 120, 94);
const BUG_AREA_B = new Rect2i(176, 20, 120, 94);
const TRAIL_LENGTH = 90;

// Each bug is drawn as a small square centered on its position, reaching BUG_REACH pixels
// out in every direction. The bug therefore has to stop BUG_REACH short of its square's
// edge, or half the marker would hang outside the frame.
const BUG_REACH = 2;
const BUG_SIZE = BUG_REACH * 2 + 1;

// Screen layout. Every scene has the same bottom row: the scene picker on the left and
// that scene's own panel on the right, both PANEL_H tall, so nothing jumps around when
// you switch. The scene's picture fills the space above.
const PANEL_H = 111;
const PANEL_Y = 240 - 4 - PANEL_H;
const NAV_PANEL_X = 4;
const NAV_PANEL_W = 100;
const SCENE_PANEL_X = 110;
const SCENE_PANEL_W = 206;

// Widgets inside a panel are 20 pixels narrower than the panel itself.
const PANEL_CONTENT_SHRINK = 20;
const NAV_BUTTON_W = NAV_PANEL_W - PANEL_CONTENT_SHRINK;
const SCENE_CONTENT_W = SCENE_PANEL_W - PANEL_CONTENT_SHRINK;

// The scene panels' value column starts after this many characters, so the longest key,
// "Right (+1)", keeps a space after it.
const SCENE_KV_COLS = 11;

// Each scene panel's rows fall short of PANEL_H by a different amount. This much empty
// space (index = MODE_* number) goes in just before each panel's closing note, so every
// panel is the same height and the note always sits at its bottom.
const NOTE_GAP = [4, 28, 18, 18, 24];

// The system font's arrows, for the Prev and Next buttons.
const ARROW_LEFT = '\u2190';
const ARROW_RIGHT = '\u2192';

// The x to print `text` at so its letters are centered on column `centerX`. The last
// character's empty spacing column is left out, so the visible letters are what centers.
function centeredTextX(centerX, text) {
    return centerX - Math.floor((text.length * FONT_W - 1) / 2);
}

// Clamps a whole number so it never leaves the range min..max. "Clamp" means "keep it
// inside the fence": if the value wandered past either end, this pushes it back to the
// nearest edge.
function clampInt(value, min, max) {
    if (value < min) {
        return min;
    }

    if (value > max) {
        return max;
    }

    return value;
}

// Five small scenes covering the parts of BT.random the other demos do not use.
class Demo {
    /** @type {Palette | null} */
    palette = null;

    // Slot map for the shared UI kit theme, filled in by applyTheme() during init().
    theme = null;

    // Which scene is on screen right now. Starts on the shuffle cards.
    mode = MODE_SHUFFLE;

    // Counts update ticks so the scenes can act every few ticks instead of every single
    // one.
    ticks = 0;

    // Shuffle scene: `deck` is the real list of cards. Keeping it apart from the copy
    // below is the whole point of this scene: one button changes only the copy, the other
    // button changes the list itself.
    deck = [];

    // The copy that shuffle() handed back.
    shuffled = [];

    // Weighted scene: how many of each tier have dropped so far.
    tierCounts = [0, 0, 0, 0];

    // The gems currently falling.
    gems = [];

    // Gaussian scene: a fixed-size ring of arrow positions.
    shots = [];

    // Which slot the next arrow overwrites, so the oldest arrow quietly disappears
    // without any array shuffling.
    shotNext = 0;

    // How far the arrows spread from the middle, set by the "Spread" slider.
    shotSpread = 18;

    // Whether arrows use gaussian() (clumped) or float() (flat).
    useGaussian = true;

    // Sign scene: where the walker stands.
    walkX = (WALK_LEFT + WALK_RIGHT) / 2;

    // How many times sign() has answered -1.
    leftCount = 0;

    // How many times sign() has answered 1.
    rightCount = 0;

    // Where the walker stood after each of its recent steps, newest first.
    walkHistory = [];

    // Directions scene: the 4-way bug remembers where it is and where it has been.
    bugA = { pos: new Vector2i(0, 0), trail: [] };

    // The 8-way bug, same shape.
    bugB = { pos: new Vector2i(0, 0), trail: [] };

    // Turns the engine overlay off: this demo needs the whole screen for its scenes and
    // panels, and has no timing or audio to watch.
    configure() {
        return {
            isOverlayEnabled: false,
        };
    }

    // Builds the palette and fills in the starting state of every scene.
    async init() {
        this.palette = BT.paletteCreate(256);

        this.palette.set(C_BG, new Color32(14, 16, 26)); // near-black blue background
        this.palette.set(C_INK, new Color32(226, 232, 244)); // bright text and outlines
        this.palette.set(C_DIM, new Color32(72, 80, 102)); // faded guides and grids
        this.palette.set(C_TARGET, new Color32(196, 148, 64)); // amber target rings
        this.palette.set(C_SHOT, new Color32(120, 226, 168)); // green arrows: gaussian()
        this.palette.set(C_SHOT_FLAT, new Color32(226, 120, 140)); // red arrows: float()
        this.palette.set(C_BUG_A, new Color32(126, 190, 255)); // blue 4-direction bug
        this.palette.set(C_BUG_B, new Color32(255, 186, 110)); // orange 8-direction bug

        // Eight card colors spread around the rainbow, so a reordering is obvious at a
        // glance. A "hue" is a position on the color wheel: 0 is red, 120 is green, 240
        // is blue, and 360 comes back around to red. Stepping evenly around the wheel is
        // the quickest way to get eight colors nobody could confuse with each other.
        for (let i = 0; i < CARD_COUNT; i++) {
            const hue = (i / CARD_COUNT) * 360;

            // fromHSL takes the hue, then how colorful it is (0-100), then how light
            // (0-100).
            this.palette.set(C_CARD_BASE + i, Color32.fromHSL(hue, 70, 62));
        }

        // Four treasure colors, getting brighter as the prize gets rarer.
        this.palette.set(C_TIER_BASE + 0, new Color32(140, 150, 165)); // common: gray
        this.palette.set(C_TIER_BASE + 1, new Color32(120, 210, 140)); // uncommon: green
        this.palette.set(C_TIER_BASE + 2, new Color32(120, 170, 255)); // rare: blue
        this.palette.set(C_TIER_BASE + 3, new Color32(255, 210, 110)); // legendary: gold

        // Install the shared UI colors, then hand the finished palette to the engine.
        // applyTheme() must come before paletteSet() so the kit's colors are included.
        this.theme = applyTheme(this.palette);

        BT.paletteSet(this.palette);

        // The deck starts in order: card 0, card 1, card 2, and so on. Starting tidy
        // means the first shuffle is easy to see.
        for (let i = 0; i < CARD_COUNT; i++) {
            this.deck.push(i);
        }

        // Before anyone presses a button, the "shuffled copy" is just a copy of the deck.
        this.shuffled = this.deck.slice();

        // Fill the arrow ring with off-screen positions so nothing is drawn until the
        // first arrow is actually fired.
        for (let i = 0; i < SHOT_COUNT; i++) {
            this.shots.push({ x: -100, y: -100, wasGaussian: true });
        }

        // Start each bug in the middle of its own square.
        this.bugA.pos = new Vector2i(
            BUG_AREA_A.x + Math.floor(BUG_AREA_A.width / 2),
            BUG_AREA_A.y + Math.floor(BUG_AREA_A.height / 2),
        );
        this.bugB.pos = new Vector2i(
            BUG_AREA_B.x + Math.floor(BUG_AREA_B.width / 2),
            BUG_AREA_B.y + Math.floor(BUG_AREA_B.height / 2),
        );

        return true;
    }

    // Advances whichever scene is currently on screen.
    update() {
        // Always first: this is what makes the { key } shortcuts on buttons work.
        ui.tick();

        this.ticks++;

        // Number keys jump straight to a scene. Reading key presses here in update() -
        // never in render() - is what keeps a quick tap from being missed.
        if (BT.isKeyPressed('Digit1')) {
            this.mode = MODE_SHUFFLE;
        }

        if (BT.isKeyPressed('Digit2')) {
            this.mode = MODE_WEIGHTED;
        }

        if (BT.isKeyPressed('Digit3')) {
            this.mode = MODE_GAUSSIAN;
        }

        if (BT.isKeyPressed('Digit4')) {
            this.mode = MODE_SIGN;
        }

        if (BT.isKeyPressed('Digit5')) {
            this.mode = MODE_DIRECTIONS;
        }

        // Only the scene being looked at needs to do any work. The shuffle scene has
        // nothing to advance - its cards only move when a button is pressed.
        if (this.mode === MODE_WEIGHTED) {
            this.updateWeighted();
        } else if (this.mode === MODE_GAUSSIAN) {
            this.updateGaussian();
        } else if (this.mode === MODE_SIGN) {
            this.updateSign();
        } else if (this.mode === MODE_DIRECTIONS) {
            this.updateDirections();
        }
    }

    // Declares both panels, then draws the current scene above them.
    //
    // The panels come first because their buttons can change what the scene shows - a new
    // scene, a reshuffled deck - and the picture drawn after them should already show it.
    // They never overlap the picture, and the engine draws shapes like the cards and the
    // target under every panel anyway, so the order costs nothing on screen.
    render() {
        BT.clear(C_BG);

        this.renderNavPanel();

        // Each scene adds its own controls and readouts in the panel beside the picker.
        ui.begin('topLeft', { x: SCENE_PANEL_X, y: PANEL_Y, width: SCENE_PANEL_W, kvCols: SCENE_KV_COLS });

        if (this.mode === MODE_SHUFFLE) {
            this.renderShufflePanel();
        } else if (this.mode === MODE_WEIGHTED) {
            this.renderWeightedPanel();
        } else if (this.mode === MODE_GAUSSIAN) {
            this.renderGaussianPanel();
        } else if (this.mode === MODE_SIGN) {
            this.renderSignPanel();
        } else {
            this.renderDirectionsPanel();
        }

        ui.end();

        if (this.mode === MODE_SHUFFLE) {
            this.renderShuffle();
        } else if (this.mode === MODE_WEIGHTED) {
            this.renderWeighted();
        } else if (this.mode === MODE_GAUSSIAN) {
            this.renderGaussian();
        } else if (this.mode === MODE_SIGN) {
            this.renderSign();
        } else {
            this.renderDirections();
        }
    }

    // The scene picker, in the same spot in every scene. Two arrow buttons rather than
    // five named ones: five rows would not fit, and the number keys still jump straight
    // to a scene. The buttons are tappable, so someone on a phone can reach every scene
    // without a keyboard.
    renderNavPanel() {
        ui.begin('topLeft', { x: NAV_PANEL_X, y: PANEL_Y, width: NAV_PANEL_W });
        ui.panel(`Scene ${this.mode + 1}/${MODE_NAMES.length}`);
        ui.label(MODE_NAMES[this.mode], { color: 'accent' });
        ui.label('or keys 1-5', { color: 'dim' });
        ui.spacer(4);

        // Adding MODE_NAMES.length before taking the remainder keeps the answer positive
        // when stepping back from scene 1.
        if (ui.button(`${ARROW_LEFT} Prev`, { width: NAV_BUTTON_W })) {
            this.mode = (this.mode + MODE_NAMES.length - 1) % MODE_NAMES.length;
        }

        if (ui.button(`Next ${ARROW_RIGHT}`, { width: NAV_BUTTON_W })) {
            this.mode = (this.mode + 1) % MODE_NAMES.length;
        }

        ui.end();
    }

    // Drops a new gem every few ticks and moves the falling ones down the screen.
    updateWeighted() {
        // `%` is the remainder after dividing, so this is true once every 8 ticks.
        if (this.ticks % DROP_EVERY_TICKS === 0) {
            this.dropGem();
        }

        // Walk the list backward so removing an item cannot make the loop skip the next
        // one.
        for (let i = this.gems.length - 1; i >= 0; i--) {
            const gem = this.gems[i];

            gem.y += 2;

            if (gem.y >= DROP_FLOOR_Y) {
                this.gems.splice(i, 1);
            }
        }
    }

    // Asks weighted() for one treasure tier and starts a gem falling.
    dropGem() {
        // weighted() picks one entry from the first list, using the second list as the
        // "how often should this win?" numbers. The weights here are 70, 20, 9, and 1, so
        // Common turns up roughly 70 times for every 1 Legendary.
        //
        // We hand it TIER_INDEXES (0, 1, 2, 3) rather than the names, because the number
        // it returns is then ready to use as a position in TIER_NAMES and as a color
        // slot.
        const tier = BT.random.weighted(TIER_INDEXES, TIER_WEIGHTS);

        this.tierCounts[tier]++;

        this.gems.push({
            // int(a, b) gives a whole number from a up to (but not including) b, so this
            // lands somewhere across the middle of the screen.
            x: BT.random.int(CHEST_RECT.x + 4, CHEST_RECT.x + CHEST_RECT.width - 10),
            y: DROP_START_Y,
            tier,
        });
    }

    // Fires an arrow at the target every few ticks.
    updateGaussian() {
        if (this.ticks % SHOT_EVERY_TICKS !== 0) {
            return;
        }

        const shot = this.shots[this.shotNext];

        if (this.useGaussian) {
            // gaussian(middle, spread) clumps its answers around the middle value. Most
            // land close to it, a few land further out, and the really wild ones are rare
            // - the same way most people are close to average height and giants are
            // unusual.
            shot.x = TARGET_CENTER.x + Math.round(BT.random.gaussian(0, this.shotSpread));
            shot.y = TARGET_CENTER.y + Math.round(BT.random.gaussian(0, this.shotSpread));
        } else {
            // float(a, b) is the flat version: every distance from the middle is equally
            // likely, so the arrows spread out into an even square with no clump at all.
            const reach = this.shotSpread * 2;

            shot.x = TARGET_CENTER.x + Math.round(BT.random.float(-reach, reach));
            shot.y = TARGET_CENTER.y + Math.round(BT.random.float(-reach, reach));
        }

        shot.wasGaussian = this.useGaussian;

        // Move to the next slot, wrapping back to 0 at the end. This is why the oldest
        // arrow vanishes without any list being rebuilt.
        this.shotNext = (this.shotNext + 1) % SHOT_COUNT;
    }

    // Steps the walker left or right.
    updateSign() {
        if (this.ticks % STEP_EVERY_TICKS !== 0) {
            return;
        }

        // sign() answers -1 or 1, nothing else. Multiplying a distance by it turns "how
        // far" into "how far, and which way" in a single step.
        const direction = BT.random.sign();

        if (direction < 0) {
            this.leftCount++;
        } else {
            this.rightCount++;
        }

        // Each step is 6 pixels; clampInt keeps the dot on the line.
        this.walkX = clampInt(this.walkX + direction * 6, WALK_LEFT, WALK_RIGHT);

        // Remember the new spot at the front of the history, and let the oldest one fall
        // off the end. unshift() adds to the front of a list, pop() removes the last
        // item.
        this.walkHistory.unshift(this.walkX);

        if (this.walkHistory.length > WALK_HISTORY) {
            this.walkHistory.pop();
        }
    }

    // Steps both bugs and records where they have been.
    updateDirections() {
        if (this.ticks % STEP_EVERY_TICKS !== 0) {
            return;
        }

        // direction4() hands back a Vector2i that is one step up, down, left, or right -
        // the four ways a rook moves in chess. No diagonals.
        this.stepBug(this.bugA, BT.random.direction4(), BUG_AREA_A);

        // direction8() adds the four diagonals, so this bug has eight choices - the way a
        // king moves in chess. Its trail ends up looking rounder and less blocky.
        this.stepBug(this.bugB, BT.random.direction8(), BUG_AREA_B);
    }

    // Moves one bug by one step and remembers the position it left behind. `step` is one
    // step from direction4() or direction8(); `area` is the square this bug is allowed to
    // wander inside.
    stepBug(bug, step, area) {
        // Remember where the bug was standing before it moved, so the trail grows behind
        // it.
        bug.trail.push(new Vector2i(bug.pos.x, bug.pos.y));

        // Once the trail is long enough, drop the oldest position off the front. shift()
        // removes the first item in a list.
        if (bug.trail.length > TRAIL_LENGTH) {
            bug.trail.shift();
        }

        // Steps are 3 pixels so the trail is easy to see. clampInt keeps the bug from
        // walking out of its own square.
        //
        // The limits are pulled in twice over. The last pixel inside a square is one
        // short of its width, so a square 120 wide starting at x = 24 ends at x = 143.
        // Then BUG_REACH comes off each end as well, because the position is the middle
        // of the marker rather than its corner - without that the square would straddle
        // the frame.
        bug.pos = new Vector2i(
            clampInt(bug.pos.x + step.x * 3, area.x + BUG_REACH, area.x + area.width - 1 - BUG_REACH),
            clampInt(bug.pos.y + step.y * 3, area.y + BUG_REACH, area.y + area.height - 1 - BUG_REACH),
        );
    }

    // Draws the two rows of cards.
    renderShuffle() {
        ui.caption(CARD_LEFT, CARD_ROW_TOP_Y - 10, 'The list itself');
        ui.caption(CARD_LEFT, CARD_ROW_BOTTOM_Y - 10, 'What shuffle() handed back', { color: 'dim' });

        this.renderCardRow(this.deck, CARD_ROW_TOP_Y);
        this.renderCardRow(this.shuffled, CARD_ROW_BOTTOM_Y);
    }

    // Draws one row of colored cards. `cards` holds the card numbers in the order they
    // should appear; `y` is the top edge of the row.
    renderCardRow(cards, y) {
        for (let i = 0; i < cards.length; i++) {
            const x = CARD_LEFT + i * CARD_STRIDE;

            // The card's number decides its color, so a card keeps its color wherever it
            // moves. That is what makes a reordering visible.
            BT.drawRectFill(new Rect2i(x, y, CARD_W, CARD_H), C_CARD_BASE + cards[i]);
            BT.drawRect(new Rect2i(x, y, CARD_W, CARD_H), C_INK);
        }
    }

    // Draws the chest and the falling gems on the left, and a bar chart of every drop so
    // far on the right.
    renderWeighted() {
        const chestMidX = CHEST_RECT.x + Math.floor(CHEST_RECT.width / 2);

        ui.caption(centeredTextX(chestMidX, 'Treasure chest'), CHEST_RECT.y - 10, 'Treasure chest');

        // The chest itself is just a box - the interesting part is what comes out of it.
        BT.drawRectFill(CHEST_RECT, C_DIM);
        BT.drawRect(CHEST_RECT, C_INK);

        // Each gem is a 6x6 square in its tier's color.
        for (const gem of this.gems) {
            BT.drawRectFill(new Rect2i(gem.x, gem.y, 6, 6), C_TIER_BASE + gem.tier);
        }

        this.renderDropChart();
    }

    // One bar per tier, as tall as that tier's share of all drops, with a faint line at
    // the share the weights asked for. Above each bar, its share in percent; under it,
    // the tier's first letter.
    renderDropChart() {
        const total = this.totalDrops();
        const chartW = TIER_NAMES.length * BAR_W + (TIER_NAMES.length - 1) * BAR_GAP;
        const weightSum = TIER_WEIGHTS.reduce((sum, weight) => sum + weight, 0);

        ui.caption(centeredTextX(BAR_LEFT + Math.floor(chartW / 2), 'Drops so far'), CHEST_RECT.y - 10, 'Drops so far');

        // The floor of the chart.
        BT.drawLine(new Vector2i(BAR_LEFT - 4, BAR_BOTTOM_Y), new Vector2i(BAR_LEFT + chartW + 3, BAR_BOTTOM_Y), C_DIM);

        for (let i = 0; i < TIER_NAMES.length; i++) {
            const x = BAR_LEFT + i * (BAR_W + BAR_GAP);
            const midX = x + Math.floor(BAR_W / 2);

            // A share is a fraction from 0 to 1, so a count is divided by the total.
            // Guarding against a total of 0 avoids dividing by zero on the very first
            // frame.
            const share = total > 0 ? this.tierCounts[i] / total : 0;
            const barH = Math.round(share * BAR_MAX_H);
            const askedY = BAR_BOTTOM_Y - Math.round((TIER_WEIGHTS[i] / weightSum) * BAR_MAX_H);

            if (barH > 0) {
                BT.drawRectFill(new Rect2i(x, BAR_BOTTOM_Y - barH, BAR_W, barH), C_TIER_BASE + i);
            }

            // The asked-for share, drawn over the bar so it shows either way.
            BT.drawLine(new Vector2i(x - 2, askedY), new Vector2i(x + BAR_W + 1, askedY), C_INK);

            // The percent sits above whichever is higher, the bar or its white line.
            const percent = `${Math.round(share * 100)}%`;
            const labelBottom = Math.min(BAR_BOTTOM_Y - barH, askedY);

            ui.caption(centeredTextX(midX, percent), labelBottom - 10, percent, { color: 'dim' });
            ui.caption(centeredTextX(midX, TIER_NAMES[i][0]), BAR_BOTTOM_Y + 5, TIER_NAMES[i][0], { color: 'dim' });
        }
    }

    // Draws the target and every arrow currently stuck in it.
    renderGaussian() {
        const ringTop = TARGET_CENTER.y - TARGET_RINGS[0];

        ui.caption(centeredTextX(TARGET_CENTER.x, 'Aim for the middle'), ringTop - 10, 'Aim for the middle');

        // Concentric squares stand in for a round target - the engine draws rectangles,
        // lines, and pixels, so a "ring" here is a square outline.
        for (const radius of TARGET_RINGS) {
            BT.drawRect(
                new Rect2i(TARGET_CENTER.x - radius, TARGET_CENTER.y - radius, radius * 2, radius * 2),
                C_TARGET,
            );
        }

        // Each arrow is a 2x2 square, green for gaussian() and red for float().
        for (const shot of this.shots) {
            BT.drawRectFill(new Rect2i(shot.x, shot.y, 2, 2), shot.wasGaussian ? C_SHOT : C_SHOT_FLAT);
        }
    }

    // Draws the walking line with the dot standing on it, and under it a column of the
    // spots the dot stood on before - newest on top, so the walk reads downward in time.
    renderSign() {
        const start = (WALK_LEFT + WALK_RIGHT) / 2;
        const label = 'Left or right, nothing else';

        ui.caption(centeredTextX(start, label), WALK_Y - 22, label);

        BT.drawLine(new Vector2i(WALK_LEFT, WALK_Y), new Vector2i(WALK_RIGHT, WALK_Y), C_DIM);

        // A tick mark at the starting point, so it is obvious how far the walker has
        // drifted, and the two ends labeled with what sign() answers to get there.
        BT.drawLine(new Vector2i(start, WALK_Y - 6), new Vector2i(start, WALK_Y + 6), C_DIM);
        ui.caption(WALK_LEFT - 2 * FONT_W - 6, WALK_Y - 2, '-1', { color: 'dim' });
        ui.caption(WALK_RIGHT + 7, WALK_Y - 2, '+1', { color: 'dim' });

        // The history: one pixel per past step, under the line.
        for (let i = 0; i < this.walkHistory.length; i++) {
            BT.drawPixel(new Vector2i(this.walkHistory[i], WALK_Y + 10 + i * WALK_HISTORY_GAP), C_DIM);
        }

        // The walker: a 7x7 square centered on the line.
        BT.drawRectFill(new Rect2i(this.walkX - 3, WALK_Y - 3, 7, 7), C_INK);
    }

    // Draws both bugs and their trails.
    renderDirections() {
        ui.caption(BUG_AREA_A.x, BUG_AREA_A.y - 10, '4 ways', { color: 'info' });
        ui.caption(BUG_AREA_B.x, BUG_AREA_B.y - 10, '8 ways', { color: 'warm' });

        BT.drawRect(BUG_AREA_A, C_DIM);
        BT.drawRect(BUG_AREA_B, C_DIM);

        this.renderBug(this.bugA, C_BUG_A);
        this.renderBug(this.bugB, C_BUG_B);
    }

    // Draws one bug's trail as single pixels, then the bug itself as a small square.
    renderBug(bug, colorSlot) {
        for (const point of bug.trail) {
            BT.drawPixel(point, C_DIM);
        }

        BT.drawRectFill(new Rect2i(bug.pos.x - BUG_REACH, bug.pos.y - BUG_REACH, BUG_SIZE, BUG_SIZE), colorSlot);
    }

    // How many gems have dropped so far, all tiers together.
    totalDrops() {
        return this.tierCounts.reduce((sum, count) => sum + count, 0);
    }

    // The two shuffle buttons, which are the whole lesson of this scene. Like every
    // scene panel, this one only declares rows: render() opens and closes the group.
    renderShufflePanel() {
        ui.panel('Shuffle');

        if (ui.button('C - shuffle() a copy', { key: 'KeyC', width: SCENE_CONTENT_W })) {
            // shuffle() builds a NEW mixed-up list and hands that back. The original list
            // is untouched, which is why the top row does not move when you press this.
            this.shuffled = BT.random.shuffle(this.deck);
        }

        if (ui.button('P - shuffleInPlace()', { key: 'KeyP', width: SCENE_CONTENT_W })) {
            // shuffleInPlace() mixes up the list you handed it. The top row jumps, and
            // the old order is gone for good - there is no copy to go back to.
            BT.random.shuffleInPlace(this.deck);
        }

        ui.spacer(NOTE_GAP[MODE_SHUFFLE]);
        ui.label('Copy: the top row stays put.', { color: 'dim' });
        ui.label('In place: the top row moves.', { color: 'dim' });
    }

    // The weights the code asked for, the shares that actually dropped, and the total.
    renderWeightedPanel() {
        const total = this.totalDrops();

        // Each tier's share of all drops in whole percent, joined with slashes to line up
        // with the asked-for row above it.
        const got = this.tierCounts.map((count) => (total > 0 ? Math.round((count / total) * 100) : 0)).join(' / ');

        ui.panel('Weighted drops');
        ui.kv('Asked', TIER_WEIGHTS.join(' / '));
        ui.kv('Got', got);
        ui.kv('Drops', total);
        ui.spacer(NOTE_GAP[MODE_WEIGHTED]);
        ui.label('The white lines mark what the', { color: 'dim' });
        ui.label('weights asked for. Rare is rare.', { color: 'dim' });
    }

    // The flat-versus-clumped switch and the spread slider.
    renderGaussianPanel() {
        ui.panel('Scatter');

        // The checkbox answers true or false; the slider answers a number, rounded to a
        // whole pixel and shown without decimals.
        this.useGaussian = ui.checkbox('G - Use gaussian()', this.useGaussian, { key: 'KeyG' });
        ui.spacer(4);
        this.shotSpread = Math.round(
            ui.slider('Spread', this.shotSpread, { min: 4, max: 40, width: SCENE_CONTENT_W, decimals: 0 }),
        );

        // The exact call each arrow makes right now, with this spread filled in.
        const reach = this.shotSpread * 2;
        const call = this.useGaussian ? `gaussian(0, ${this.shotSpread})` : `float(-${reach}, ${reach})`;

        ui.label(`Each arrow: ${call}`, { color: 'info' });
        ui.spacer(NOTE_GAP[MODE_GAUSSIAN]);
        ui.label(this.useGaussian ? 'Green: clumped near the middle.' : 'Red: even, edge to edge.', {
            color: 'dim',
        });
    }

    // How many times sign() answered each way.
    renderSignPanel() {
        ui.panel('Coin flips');
        ui.kv('Left (-1)', this.leftCount);
        ui.kv('Right (+1)', this.rightCount);
        ui.kv('Total', this.leftCount + this.rightCount);

        // How far the walker has drifted from the tick mark, in 6-pixel steps.
        const drift = (this.walkX - (WALK_LEFT + WALK_RIGHT) / 2) / 6;

        ui.kv('Drift', `${drift > 0 ? '+' : ''}${drift} steps`);
        ui.spacer(NOTE_GAP[MODE_SIGN]);
        ui.label('Close to even over time,', { color: 'dim' });
        ui.label('but never exactly even.', { color: 'dim' });
    }

    // A reminder of what separates the two bugs.
    renderDirectionsPanel() {
        ui.panel('Two bugs');
        ui.label('Blue: direction4()', { color: 'info' });
        ui.label('up, down, left, right', { color: 'dim' });
        ui.spacer(4);
        ui.label('Orange: direction8()', { color: 'warm' });
        ui.label('those four, plus diagonals', { color: 'dim' });
        ui.spacer(NOTE_GAP[MODE_DIRECTIONS]);
        ui.label('Both hand back a Vector2i.', { color: 'dim' });
    }
}

bootstrap(Demo);