BLIT386BLIT386

Example: Basics

Your first BLIT386 program: the engine lifecycle, a bouncing sprite, a palette, and text on the canvas.

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

basics.js
/*

Basics: your first BLIT386 program - the lifecycle, a palette, and a bouncing sprite.
@description Your first BLIT386 program: the engine lifecycle, a bouncing sprite, a palette, and text on the canvas.

Part of the BLIT386 demo series.

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

Guides:
  - Game loop: https://blit386.dev/docs/guides/game-loop#smoothing-motion-between-updates
  - Palette: https://blit386.dev/docs/guides/palette#index-sprites-preferred-and-manual-flows
  - Overlay: https://blit386.dev/docs/guides/overlay#custom-rows

Reference:
  - Core: https://blit386.dev/docs/api/core#default-configuration
  - 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 dark green screen with the BLIT386 logo sprite drifting diagonally across it. When the
sprite reaches an edge it bounces back, like a ball in a very slow game of Pong. Two dim
lines of text in the top-left corner tell you how to open the engine overlay.

With the overlay open you also get a timing chart under the top rows, a one-row palette
grid at the bottom, and two custom rows just above it: the sprite's position and how many
times it has bounced.

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

- How a demo is structured: configure(), init(), update(), render(), plus the
  optional overlayRows() hook.
- Palettes: BT.paletteCreate(256), palette.set(slot, color), and BT.paletteSet(palette).
  Draw calls name a slot number, never a color.
- Loading a sprite with SpriteSheet.loadIndexed(url, palette, startSlot, { sort: 'none' })
  and drawing it with BT.drawSprite(sheet, srcRect, pos, paletteOffset).
- Moving the sprite with Vector2i.add() and bouncing it off the screen edges.
- Smooth motion when update() and render() run at different rates: BT.renderAlpha and
  Vector2i.lerp(prevPos, pos, alpha).
- A text hint with the shared UI kit: ui.begin(), ui.label(text, { color:
  'dim' }), ui.end().
- Live stats in the engine overlay through overlayRows(), and timing-chart
  tags with BT.assignTag(label).

If you are new to BLIT386, read this file from top to bottom. Almost every block has a
comment explaining what it does and why.

This demo sets targetFPS to 30 in configure() - slower than the engine default of 60 - so
the motion is easy to follow. update() therefore runs about 30 times per second.

UPDATE VS RENDER
----------------

- update() runs at a FIXED rate: targetFPS from configure(). It is where all game logic
  lives: move things, check for walls, count bounces. If the computer falls behind, the
  engine calls update() several times in a row to catch up, but never more than eight
  times in one frame.

- render() runs ONCE per screen refresh. That is often 60 times per second on a laptop
  screen, but it can be 120 or 144 on a gaming monitor. It is where all drawing happens.
  Never put game logic here - it would run at different speeds on different monitors.

- The engine draws its overlay after render() returns, so a demo never has to print the
  FPS or its own title.

When you switch to another browser tab, BOTH update() and render() stop: the browser
stops calling them to save battery. When you come back, the engine runs up to eight
update() calls to catch up, so the game does not jump far ahead in time.

TRY THIS
--------

- Press the backquote key (`) or click/tap the small symbol in the bottom-left corner to
  open the engine overlay. The two custom rows above the palette grid show the sprite's
  position and the bounce count.
- Watch the timing chart under the top rows: each bounce drops an H (horizontal)
  or V (vertical) tag on it, so you can line up a bounce with that frame's
  update() and render() time.
- The palette grid at the bottom shows 16 slots per row, one row at a time - scroll it
  with the mouse wheel or by dragging to see the rest. Slots drawn this frame show their
  color; unused slots look dim.

*/

// "import" loads tools from the BLIT386 engine library - like opening a toolbox before
// you start building.
//   - bootstrap: starts the engine and connects your demo to it.
//   - BT: the main engine object - you call BT.clear(), BT.drawSprite(), and so on.
//   - Color32: one color made of Red, Green, Blue, and an optional Alpha.
//   - SpriteSheet: a loaded image you can draw pieces of on screen (a "sprite").
//   - Vector2i: a 2D point or direction using whole numbers (x, y).
import { bootstrap, BT, Color32, SpriteSheet, Vector2i } from 'blit386';

// The shared demo UI kit. Every demo in this series uses it for on-screen text and panels
// so they all look the same. applyTheme() installs the kit's colors into our palette, and
// ui.* draws things like the hint label in the top-left corner.
import { applyTheme, ui, UI_ANCHORS } from './shared/ui.js';

// These @typedef lines tell code editors what we mean by Palette, SpriteSheet, and
// Rect2i. They do not change how the demo runs.
/** @typedef {import('blit386').Palette} Palette */
/** @typedef {import('blit386').SpriteSheet} SpriteSheet */
/** @typedef {import('blit386').Rect2i} Rect2i */

// BLIT386 uses a "palette": a numbered list of colors you choose BEFORE drawing. Think of
// an artist squeezing paints onto a tray before starting a painting. Each color gets a
// number (an "index"), and when we draw we say "use color number 1" instead of spelling
// out the color every time.
//
// Index 0 is always transparent (completely invisible). Our own colors start at 1.
const C_BG = 1; // almost-black with a faint green tint: the screen background
const C_OVERLAY_BAR = 2; // slightly lighter bar behind the overlay text
const C_OVERLAY_GREEN = 3; // bright green for the position row in the overlay
const C_OVERLAY_AMBER = 4; // amber (warm yellow) for the bounce count in the overlay
const C_OVERLAY_ERROR = 5; // red the timing chart uses when a frame is badly late

// Where the sprite's own colors begin in the palette. Slots 1-5 hold the background and
// overlay colors. Starting the sprite at 10 leaves slots 6-9 free, so we could add more
// scene colors later without renumbering the sprite slots.
const SPRITE_BASE = 10;

// Path to the sprite image. The "public" folder is served at the site root, so
// /sprites/logo-1.png maps to public/sprites/logo-1.png on disk.
const SPRITE_URL = '/sprites/logo-1.png';

// A bouncing-sprite demo: a friendly first BLIT386 program.
//
// Every BLIT386 demo is a class the engine drives. Three methods are required;
// configure() and overlayRows() are optional extras this file also uses:
//
//   1. configure() - optional. If you define it, the engine calls it once at the very
//      start so you can change settings (FPS, overlay options, and more). If you skip
//      it, you get sensible defaults: 320x240 logical pixels, a 640x480 drawing
//      buffer, 60 FPS.
//
//   2. init() - called once after the renderer is ready. This is where you load
//      images, set up colors, and pick starting positions. It is "async" because
//      loading files takes time and we need to wait for them to finish, like waiting
//      for a web page to load.
//
//   3. update() - called at the targetFPS rate (30 per second in this demo). This is
//      where you move the sprite, flip its speed when it hits a wall, and count bounces.
//      It runs at a FIXED pace so motion matches on fast and slow computers. See the file
//      header for the full explanation.
//
//   4. render() - called once per screen refresh to draw everything. Clear the screen,
//      draw shapes, print text - all drawing goes here.
//
//   5. overlayRows() - optional. Feeds extra text rows into the engine overlay (the HUD
//      you toggle with the backquote key). Handy for live stats.
class Demo {
    // "pos" is short for "position": where the sprite is on screen. Vector2i holds two
    // whole numbers, x (horizontal) and y (vertical). (0, 0) is the top-left corner; x
    // grows to the right and y grows DOWNWARD - the opposite of a math-class graph. This
    // is a placeholder near the screen center; init() replaces it with the exact center.
    pos = new Vector2i(160, 120);

    // "speed" is how many pixels the sprite moves each update(): x=1 is one pixel to the
    // right per tick, y=1 is one pixel down per tick. A negative number means the
    // opposite direction (left instead of right, up instead of down).
    speed = new Vector2i(1, 1);

    // "prevPos" remembers where the sprite was at the START of the most recent update()
    // tick, before that tick moved it. render() blends it toward pos using
    // BT.renderAlpha to draw the sprite smoothly between ticks - see the long comment
    // above render(). It starts equal to pos (both point at the same placeholder), so
    // the very first render() has nothing to blend between; init() updates both once
    // the real position is known.
    prevPos = this.pos;

    // "size" is how big the sprite is. 16x16 is a guess; init() replaces it with the
    // loaded image's real size, so the bounce checks stay correct even if you swap the
    // PNG for a bigger one.
    size = new Vector2i(16, 16);

    // "bounces" counts how many times the sprite has hit a wall. The overlay shows
    // it going up.
    bounces = 0;

    // "palette" holds the list of colors the engine draws with. We create it in init().
    /** @type {Palette | null} */
    palette = null;

    // "spriteSheet" is the loaded image we draw. It stays null until init() finishes
    // loading the PNG.
    /** @type {SpriteSheet | null} */
    spriteSheet = null;

    // "spriteRect" tells the engine WHICH rectangular piece of the image to draw. A
    // sprite sheet can hold many sprites in one big picture. Our PNG holds just one, so
    // the rectangle covers the whole image.
    /** @type {Rect2i | null} */
    spriteRect = null;

    // Reused every frame for the engine overlay (position and bounces). We keep one
    // array and rewrite the text strings in place, so no new objects are created on
    // every screen refresh.
    overlayRowData = [
        { leftText: 'Position 0, 0', textPaletteIndex: C_OVERLAY_GREEN },
        { leftText: 'Bounces 0', textPaletteIndex: C_OVERLAY_AMBER },
    ];

    // Called once at the very start. Returns the settings the engine should use.
    // This demo keeps the default screen size (320x240 logical pixels in a 640x480
    // drawing buffer); it only slows update() down and turns on helpful overlay
    // tools for learning.
    configure() {
        // We only change the settings listed below. Everything else comes from
        // the engine's defaultConfig().
        return {
            // How often update() should run. 30 times per second is slower than the
            // engine default of 60, so the bounce is easier to watch.
            targetFPS: 30,

            // Live palette grid at the bottom of the overlay: every palette slot as
            // a tiny color chip. Slots drawn this frame show their color; unused
            // slots look dim.
            isOverlayPaletteEnabled: true,

            // Show 16 chips per row and only 1 row at a time (scroll the rest with the
            // mouse wheel or by dragging).
            overlayPaletteColumns: 16,
            overlayPaletteRowsVisible: 1,

            // Scrolling timing chart under the top rows. Green dots show update() time,
            // amber dots show render() time, one column per screen refresh - handy for
            // spotting when work spikes.
            isOverlayTimingChartEnabled: true,
            overlayTimingChartHeight: 32,

            // Which palette slots the overlay bars use: the top rows, the bottom strip,
            // and the bar behind our custom rows.
            overlayStyle: {
                barPaletteIndex: C_OVERLAY_BAR,
                textPaletteIndex: C_OVERLAY_GREEN,
                gapPaletteIndex: C_BG,
            },

            // Colors for the timing chart: green for update, amber for render, amber
            // again for "a bit late", red for "badly late", and green for our H/V tags.
            overlayTimingChartStyle: {
                updateBarPaletteIndex: C_OVERLAY_GREEN,
                renderBarPaletteIndex: C_OVERLAY_AMBER,
                warningPaletteIndex: C_OVERLAY_AMBER,
                errorPaletteIndex: C_OVERLAY_ERROR,
                tagPaletteIndex: C_OVERLAY_GREEN,
            },
        };
    }

    // Called once after the renderer is ready, with the settings from configure() merged
    // over the engine defaults. Sets up the palette, loads the sprite, and centers it.
    //
    // The "async" keyword lets us use "await" inside this method. "await" pauses until
    // something slow finishes (like loading an image from the server) and then continues
    // with the result. Returns true when everything is ready; returning false would tell
    // the engine that something went wrong.
    async init() {
        // Step 1: set up the color palette. BT.paletteCreate(256) makes a new empty
        // palette with room for 256 colors - a common size in retro games and plenty
        // for this demo.
        this.palette = BT.paletteCreate(256);

        // Fill in the colors for the background and overlay text. palette.set(slot,
        // color) stores one color in a numbered slot. Color32(Red, Green, Blue) takes
        // each value from 0 (none of that color) to 255 (as much as possible).
        this.palette.set(C_BG, new Color32(16, 28, 16)); // almost-black, faint green tint

        // Overlay colors (must match overlayStyle and overlayRowData above).
        this.palette.set(C_OVERLAY_BAR, new Color32(24, 44, 28)); // dark bar behind text
        this.palette.set(C_OVERLAY_GREEN, new Color32(80, 200, 110)); // bright green text
        this.palette.set(C_OVERLAY_AMBER, new Color32(220, 180, 60)); // bounce row amber
        this.palette.set(C_OVERLAY_ERROR, new Color32(200, 70, 70)); // timing chart red

        // Step 2: load the sprite AND put its colors into the palette. The engine draws
        // sprites with palette numbers, not raw Red/Green/Blue, so every color in the PNG
        // must live in a palette slot first.
        //
        // SpriteSheet.loadIndexed() does the whole job in one call:
        //   1. Opens the PNG and writes each unique color into the palette,
        //      starting at SPRITE_BASE.
        //   2. Loads the image as a sprite sheet.
        //   3. "Indexizes" it: turns every pixel into a palette number, like labeling
        //      each paint blob with the slot it matches.
        //   4. Returns the sheet plus a rectangle that covers the whole image.
        //
        // { sort: 'none' } keeps the colors in the order they appear in the file (left to
        // right, top to bottom). The default sorts them darkest-first instead - fine for
        // many games, but file order means the slots match the PNG layout if you peek at
        // the overlay palette grid.
        //
        // We "await" because reading the PNG takes a moment.
        const indexed = await SpriteSheet.loadIndexed(SPRITE_URL, this.palette, SPRITE_BASE, { sort: 'none' });
        this.spriteSheet = indexed.sheet;
        this.spriteRect = indexed.srcRect;

        // Step 3: install the shared UI theme. applyTheme() writes the series' twelve
        // standard UI colors into high palette slots (240-251 by default, far from our
        // low scene slots), so the kit's hint label in render() has colors to draw with.
        // It must run BEFORE BT.paletteSet() below so those colors go live with the
        // palette. (It also returns a map of slot names, which this demo does not need.)
        applyTheme(this.palette);

        // Step 4: activate the palette. This tells the engine "use this palette from now
        // on." We do it AFTER adding the sprite and UI colors so they are all included.
        BT.paletteSet(this.palette);

        // Step 5: remember the sprite's pixel size. The sheet exposes its dimensions
        // through .size; we copy them into our own vector so the bounce checks use the
        // real image size, not the 16x16 guess from above.
        this.size = new Vector2i(this.spriteSheet.size.x, this.spriteSheet.size.y);

        // Step 6: center the sprite. BT.displaySize is how big the screen is (320x240 by
        // default). Subtracting the sprite's size centers the sprite itself, not its
        // top-left corner. Math.floor() rounds down to a whole number, because you cannot
        // draw at position 160.5 on a pixel screen.
        const screen = BT.displaySize;
        const x = Math.floor(screen.x / 2 - this.size.x / 2);
        const y = Math.floor(screen.y / 2 - this.size.y / 2);
        this.pos = new Vector2i(x, y);

        // Keep prevPos in step with the real starting position, so the very first
        // render() does not slide in from the (160, 120) placeholder.
        this.prevPos = this.pos;

        // Return true to tell the engine: "Everything loaded fine, start the demo!"
        return true;
    }

    // Called at a fixed rate: 30 times per second in this demo.
    //
    // This is where ALL game logic goes: moving things, checking collisions, counting
    // scores. Never draw anything here - that belongs in render().
    //
    // update() may run 0 to 8 times between screen refreshes:
    //   - Usually about 30 times per second (our targetFPS). On a 60 Hz monitor that is
    //     one update for every two screen refreshes.
    //   - If the computer is slow, it runs several times in a row to catch up.
    //   - If you switch to another browser tab, it pauses completely.
    //   - When you come back, it runs up to 8 times to catch up.
    //
    // Each call to update() is one "tick". BT.ticks tells you how many ticks have
    // happened since the demo started.
    update() {
        // Remember where the sprite was BEFORE this tick moves it. render() uses
        // this to draw a smooth in-between position instead of a hop - see the
        // comment above render().
        this.prevPos = this.pos;

        // Move the sprite by adding its speed to its position. Think of it like taking
        // steps: standing at 160 with a speed of 1, after one step you are at 161. .add()
        // returns a new Vector2i with both numbers added together.
        this.pos = this.pos.add(this.speed);

        // Wall test for left and right. pos is the sprite's TOP-LEFT corner, so its right
        // edge is at pos.x + size.x. We compare against displaySize.x - size.x: the
        // farthest right the corner may go while the whole sprite still fits on screen.
        //
        // We only flip the speed here - we do not push the sprite back onto the edge. So
        // for one tick it can sit one pixel past the wall, then travel inward again. That
        // is normal for a simple bounce.
        if (this.pos.x <= 0 || this.pos.x >= BT.displaySize.x - this.size.x) {
            // Bounce: negating speed.x reverses the horizontal direction. If it was 1
            // (going right), it becomes -1 (going left).
            this.speed.x = -this.speed.x;

            // Count this as a bounce.
            this.bounces++;

            // Mark the moment on the overlay's timing chart. Each tag scrolls left with
            // the green and amber dots, so you can line up a spike in update or render
            // time with the moment the logo hit a wall.
            BT.assignTag('H');
        }

        // Same check for the top and bottom edges.
        if (this.pos.y <= 0 || this.pos.y >= BT.displaySize.y - this.size.y) {
            // Flip the vertical speed.
            this.speed.y = -this.speed.y;
            this.bounces++;

            // Same timing-chart marker as the left/right bounce above.
            BT.assignTag('V');
        }
    }

    // Called once per screen refresh to draw everything.
    //
    // render() runs AFTER that frame's update() calls, so every position and count is
    // already final. render() just reads those values and draws the picture.
    //
    // IMPORTANT: render() runs at your monitor's refresh rate - often 60, but 120 or
    // 144 on gaming displays. Do NOT put game logic here; it would run at different
    // speeds on different monitors.
    //
    // Every frame you must clear the screen and redraw everything from scratch. If you
    // skip the clear, the old frame stays and new drawings pile up on top (which can look
    // cool, but is usually a bug).
    //
    // WHY THE SPRITE WOULD STUTTER WITHOUT THE FIX BELOW: this demo sets targetFPS to 30,
    // but render() still runs at your monitor's full refresh rate. So update() (which
    // moves the sprite) and render() (which draws it) run at DIFFERENT speeds. On a 60 Hz
    // monitor render() runs roughly twice for every update() - if it just drew this.pos
    // every time, the sprite would sit still for one refresh, hop forward, sit still
    // again. That hop-hop-hop motion is "stutter".
    //
    // THE FIX: BT.renderAlpha. Think of a movie: update() ticks are the film frames (one
    // every two screen refreshes here), and render() is a projector that can run faster
    // than the film advances. BT.renderAlpha says how far along we are between the last
    // film frame and the next one: a fraction from 0 (the last update() just finished) up
    // to just under 1 (the next update() is about to happen). We use it to blend prevPos
    // (where the sprite WAS) toward pos (where it IS), so every render() draws the sprite
    // at its true in-between position.
    render() {
        // Clear the whole screen to the background color, erasing the previous frame.
        // C_BG is palette slot 1, set to (16, 28, 16) in init(): almost black with a
        // faint green tint.
        BT.clear(C_BG);

        // Blend prevPos toward pos by BT.renderAlpha to get the sprite's true position at
        // this exact moment. Vector2i.lerp(a, b, t) walks a fraction t of the way from a
        // to b: t=0 gives a (prevPos), t=1 gives b (pos), and anything between is a
        // smooth blend - exactly the fraction BT.renderAlpha provides each frame.
        const drawPos = Vector2i.lerp(this.prevPos, this.pos, BT.renderAlpha);

        // Draw the bouncing sprite at its smoothed position. BT.drawSprite takes (sheet,
        // sourceRect, destinationPosition, paletteOffset):
        //   - sheet: the loaded image to draw from.
        //   - sourceRect: WHICH part of the image to draw. Our sprite fills the whole
        //     image, so spriteRect is the full image bounds.
        //   - destinationPosition: WHERE on screen to draw it (the top-left corner).
        //   - paletteOffset: a number added to every pixel's palette index. 0 keeps the
        //     original colors; bigger numbers swap to alternate "team colors", a trick a
        //     later demo shows.
        BT.drawSprite(this.spriteSheet, this.spriteRect, drawPos, 0);

        // On-canvas hint drawn with the shared UI kit. ui.begin() and ui.end() open and
        // close a small group of UI rows; with no ui.panel() call the group is floating
        // text with no box around it. UI_ANCHORS.TOP_LEFT puts it in the top-left corner,
        // and { color: 'dim' } picks the kit's muted gray so the hint stays out of the
        // way. (Under the hood the kit prints with the same built-in 6x14 system font.)
        // "~" is the backquote key, usually under Esc and left of the 1 key.
        ui.begin(UI_ANCHORS.TOP_LEFT);
        ui.label('Press ~ or click/tap the symbol below', { color: 'dim' });
        ui.label('to toggle the overlay', { color: 'dim' });
        ui.end();

        // The live stats (position, bounce count) come from overlayRows() in the engine
        // overlay. The overlay also shows FPS, the backend, and this demo's title, so we
        // do not duplicate those here.
    }

    // Optional hook: feeds extra text rows into the engine overlay (not the game canvas).
    //
    // The overlay is the HUD the engine draws after render(): FPS, demo title, timing
    // chart, palette grid, and these custom rows. Toggle it with the backquote key or by
    // clicking or tapping the symbol in the bottom-left corner. The engine calls this
    // once per frame while the overlay is open. Each row is plain text plus a palette
    // index for its color. We reuse overlayRowData every frame and only rewrite the
    // strings - no new arrays.
    overlayRows() {
        this.overlayRowData[0].leftText = `Position (${this.pos.x}, ${this.pos.y})`;
        this.overlayRowData[1].leftText = `Bounces ${this.bounces}`;

        return this.overlayRowData;
    }
}

// bootstrap() starts everything. You pass it your Demo class, and it takes care of:
//   1. Setting up the HTML canvas on the page.
//   2. Picking a backend: WebGPU when the browser supports it, otherwise the Canvas
//      2D software renderer.
//   3. Creating a new instance of your Demo class.
//   4. Calling configure() when you define it, then init(), then the update/render loop.
//
// After this line runs, your demo is alive and running.
bootstrap(Demo);