BLIT386BLIT386

Starter Template

The four files of a new BLIT386 game - package.json, index.html, vite.config.js, src/game.js.

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.

This is the JavaScript project npm create blit386@latest my-game writes, minus the AI-assistant and formatter files. Put these four files in an empty folder, run npm install, then npm run dev. The game in src/game.js is a small, complete example: a paddle that catches falling blocks, with keyboard and pointer input, a palette, a score, and rectangle collision. Replace it with your own.

package.json

package.json
{
  "name": "my-game",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=22.18.0"
  },
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "format": "biome format --write . && prettier --write \"**/*.{md,mdx,mdc,yml,yaml}\"",
    "format:check": "biome format . && prettier --check \"**/*.{md,mdx,mdc,yml,yaml}\"",
    "lint": "biome lint .",
    "lint:fix": "biome lint --write ."
  },
  "dependencies": {
    "blit386": "^1.8.0"
  },
  "devDependencies": {
    "@biomejs/biome": "^2.5.7",
    "@blit386/kit": "^1.8.0",
    "prettier": "^3.9.6",
    "vite": "^8.1.5"
  }
}

index.html

index.html
<!doctype html>

<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>my-game</title>
        <style>
            *,
            *::before,
            *::after {
                box-sizing: border-box;
            }

            html,
            body {
                margin: 0;
                height: 100%;
                overflow: hidden;
            }

            body {
                background: black;
            }

            #canvas-container {
                width: 100dvw;
                height: 100dvh;
                display: flex;
                align-items: center;
                justify-content: center;
            }

            /*
             * Scale the pixel canvas up to fill the window while keeping its aspect ratio and staying crisp.
             * The engine sets the --canvas-* custom properties; the fallbacks here keep things sensible if it does not.
             */
            #canvas-container canvas {
                display: block;
                outline: none;
                image-rendering: pixelated;
                width: min(
                    100dvw,
                    calc(100dvh * var(--canvas-aspect-w, 640) / var(--canvas-aspect-h, 480)),
                    var(--canvas-max-w, 1280px)
                );
                height: min(
                    100dvh,
                    calc(100dvw * var(--canvas-aspect-h, 480) / var(--canvas-aspect-w, 640)),
                    var(--canvas-max-h, 960px)
                );
            }

            @supports (image-rendering: crisp-edges) {
                #canvas-container canvas {
                    image-rendering: crisp-edges;
                }
            }
        </style>
    </head>

    <body>
        <div id="canvas-container"><canvas id="blit386-canvas"></canvas></div>
        <script type="module" src="/src/game.js"></script>
    </body>
</html>

vite.config.js

vite.config.js
import { defineConfig } from 'vite';
import { blit386 } from 'blit386/vite';

// Vite is the little web server that runs your game while you work on it.
// The blit386() plugin makes your edits appear in the running game without
// restarting it, keeping your score and position when possible (hot reload).
// You usually do not need to change anything else here.
export default defineConfig({
    plugins: [blit386()],
});

src/game.js

src/game.js
// my-game - a tiny BLIT386 game called "Catcher".
//
// Move the paddle to catch the blocks falling from the top.
// On a phone or tablet: drag or tap - the paddle centers under your finger.
// On a computer: move the mouse, or use the left and right arrow keys (or A and D) as a fallback.
// Catch one: +1 point. Miss one: lose a life. Run out of lives and the game starts over.
//
// Every BLIT386 game is one class with three required methods:
//   init()   - runs once at the start (we set up our colors here).
//   update() - runs about 60 times a second (we move things and check for catches here).
//   render() - runs about 60 times a second (we draw everything here).
//
// Plus optional hooks. This file uses testState(), which hands the score, lives, and positions to a play-tester as
// plain numbers (engine 1.8.0+). Two more you can add later: configure() for screen settings or to turn off the
// BLIT386 splash, and onHotReload() to keep score across init() edits while the Vite plugin hot-reloads (see the
// commented examples below).
//
// We do not write a configure() method, so we get the default screen: 320 by 240 pixels at 60 frames per second.
// Want to learn more? Read AGENTS.md or the docs/ folder next to this file.
//
// Testing tip: add `?seed=42` to the page address to get the same falling blocks every run, and open the browser
// console and type `BT.testState()` to see the score, lives, and positions as numbers.

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

// Color slot numbers. We put real colors into these slots in init(), then draw using the numbers.
// Slot 0 is always transparent, so we start counting at 1.
const COLOR_BACKGROUND = 1;
const COLOR_PADDLE = 2;
const COLOR_ITEM = 3;
const COLOR_TEXT = 4;

// Sizes and speeds. These are the fun knobs to turn. Change a number, save, and watch what happens.
const PADDLE_WIDTH = 48;
const PADDLE_HEIGHT = 8;
const ITEM_SIZE = 10;
const PADDLE_SPEED = 3; // how many pixels the paddle moves each step
const ITEM_FALL_SPEED = 2; // how many pixels a block falls each step
const SPAWN_EVERY = 45; // a new block appears every this many steps (60 steps is about one second)
const STARTING_LIVES = 3;

// Read `?seed=1234` from the page address. The same seed makes the blocks fall in the same places every run,
// which is how you replay a bug or give a test a fixed starting point. No seed means a different game every time.
/** @returns {number | null} */
function readSeed() {
    const raw = new URLSearchParams(window.location.search).get('seed');

    if (raw === null) {
        return null;
    }

    const seed = Number(raw);

    if (!Number.isSafeInteger(seed)) {
        console.warn(`[game] Ignoring ?seed=${raw}: it must be a whole number.`);

        return null;
    }

    return seed;
}

// Turn a PNG blob into a data URL string, which a browser tool can read back out of the page.
/**
 * @param {Blob} blob
 * @returns {Promise<string>}
 */
function blobToDataURL(blob) {
    return new Promise((resolve, reject) => {
        const reader = new FileReader();

        reader.onload = () => resolve(String(reader.result));
        reader.onerror = () => reject(reader.error);
        reader.readAsDataURL(blob);
    });
}

class Game {
    // How big the screen is. We read the real size in init().
    screen = new Vector2i(320, 240);

    // The paddle's initial position.
    paddlePos = new Vector2i(0, 0);

    // The blocks falling right now. Each one is a Vector2i holding its top-left corner.
    /** @type {Vector2i[]} */
    items = [];

    // The player's score and lives.
    score = 0;
    lives = STARTING_LIVES;

    async init() {
        // Remember the screen size so the game fits no matter how big it is.
        this.screen = BT.displaySize;

        // Make a palette (a numbered set of colors) and choose four colors.
        // Color32(red, green, blue) - each value goes from 0 (none) to 255 (full).
        const palette = BT.paletteCreate(16);

        palette.set(COLOR_BACKGROUND, new Color32(18, 22, 40)); // dark blue
        palette.set(COLOR_PADDLE, new Color32(90, 200, 160)); // teal
        palette.set(COLOR_ITEM, new Color32(240, 180, 70)); // warm yellow
        palette.set(COLOR_TEXT, new Color32(235, 240, 255)); // near white

        BT.paletteSet(palette);

        // Put the paddle in the middle, near the bottom.
        this.paddlePos.x = Math.floor((this.screen.x - PADDLE_WIDTH) / 2);
        this.paddlePos.y = this.screen.y - PADDLE_HEIGHT - 6;

        // Out of the box, player 0 steers with WASD and the arrow keys belong to player 1.
        // This game has one player, so let both sets of keys move the paddle.
        BT.inputMap(0, BT.BTN_LEFT, 'KeyA', 'ArrowLeft');
        BT.inputMap(0, BT.BTN_RIGHT, 'KeyD', 'ArrowRight');

        const seed = readSeed();

        if (seed !== null) {
            BT.randomSeed(seed);
        }

        // Dev builds only: let tests and AI agents grab exact frames, and read the state the old way.
        // A shipped game (a production build) never has `window.__game`. The state itself comes from
        // testState() below - `BT.testState()` is the newer way to read it and needs no global at all.
        if (BT.isDevMode) {
            // Object.assign instead of `window.__game = ...`, because the editor's type checker does not know
            // about a `__game` property on `window` in a plain JavaScript file.
            Object.assign(window, {
                __game: {
                    state: () => this.testState(),
                    frame: async () => blobToDataURL(await BT.captureFrame()),
                },
            });
        }

        return true; // tell the engine that setup worked
    }

    update() {
        // BLIT386 supports two ways to move the paddle: pointer input (mouse and touch) and the keyboard.
        // We check the pointer first because it works on phones, tablets, and any computer with a mouse.
        // The "0" you see in BT.isPointerActive(0) and BT.pointerPos(0) means "the first pointer slot."
        // A phone can track several fingers at once; slot 0 is always the first (or only) one.
        if (BT.isPointerActive(0)) {
            // BT.pointerPos(0) returns a Vector2i: the exact pixel position of the pointer right now.
            // We want the CENTER of the paddle under the pointer, not its left edge.
            // Subtracting half the paddle width shifts it left so it is balanced around the cursor or finger.
            this.paddlePos.x = BT.pointerPos(0).x - Math.floor(PADDLE_WIDTH / 2);
        } else {
            // No pointer is active - fall back to the arrow keys, A and D (or a connected gamepad).
            // BT.isDown() is true for every frame the button is held down, not just the frame it was pressed.
            if (BT.isDown(BT.BTN_LEFT, 0)) {
                this.paddlePos.x -= PADDLE_SPEED;
            }

            if (BT.isDown(BT.BTN_RIGHT, 0)) {
                this.paddlePos.x += PADDLE_SPEED;
            }
        }

        // Keep the paddle on the screen no matter how it moved.
        // Math.floor makes sure we store a whole number, not a fraction of a pixel.
        const maxX = this.screen.x - PADDLE_WIDTH;
        if (this.paddlePos.x < 0) {
            this.paddlePos.x = 0;
        }
        if (this.paddlePos.x > maxX) {
            this.paddlePos.x = maxX;
        }

        // Every SPAWN_EVERY steps, drop a new block at a random spot along the top.
        if (BT.ticks % SPAWN_EVERY === 0) {
            // BT.random.int(n) picks a whole number from 0 up to (but not including) n. Unlike the browser's own random,
            // BT.random can be seeded: a `?seed=` in the address replays the exact same drops.
            const x = BT.random.int(this.screen.x - ITEM_SIZE);
            this.items.push(new Vector2i(x, -ITEM_SIZE));
        }

        // The paddle as a rectangle, used to check for catches.
        const paddleRect = new Rect2i(this.paddlePos.x, this.paddlePos.y, PADDLE_WIDTH, PADDLE_HEIGHT);

        // Move each block down, then decide: caught, missed, or still falling.
        /** @type {Vector2i[]} */
        const stillFalling = [];
        for (const item of this.items) {
            item.y += ITEM_FALL_SPEED;
            const itemRect = new Rect2i(item.x, item.y, ITEM_SIZE, ITEM_SIZE);

            if (paddleRect.isIntersecting(itemRect)) {
                this.score += 1; // the paddle touched it: caught
            } else if (item.y > this.screen.y) {
                this.lives -= 1; // it fell off the bottom: missed
            } else {
                stillFalling.push(item); // still on its way down
            }
        }
        this.items = stillFalling;

        // Out of lives? Start a fresh game.
        if (this.lives <= 0) {
            this.score = 0;
            this.lives = STARTING_LIVES;
            this.items = [];
        }
    }

    render() {
        // Paint the background first. This also erases last frame's drawing.
        BT.clear(COLOR_BACKGROUND);

        // Draw every falling block.
        for (const item of this.items) {
            BT.drawRectFill(new Rect2i(item.x, item.y, ITEM_SIZE, ITEM_SIZE), COLOR_ITEM);
        }

        // Draw the paddle.
        BT.drawRectFill(new Rect2i(this.paddlePos.x, this.paddlePos.y, PADDLE_WIDTH, PADDLE_HEIGHT), COLOR_PADDLE);

        // Show the score and lives in the top-left corner.
        BT.systemPrint(new Vector2i(6, 6), COLOR_TEXT, `Score ${this.score}`);
        BT.systemPrint(new Vector2i(6, 18), COLOR_TEXT, `Lives ${this.lives}`);
    }

    // The game as plain numbers, for a test or an AI agent that cannot read pixels. The engine never calls this on
    // its own: while the dev server runs, `BT.testState()` in the browser console (or `npx blit play`) reads it.
    // Add a field here when you want to check something new, for example `level: this.level`.
    testState() {
        return {
            ticks: BT.ticks,
            score: this.score,
            lives: this.lives,
            paddle: { x: this.paddlePos.x, y: this.paddlePos.y, width: PADDLE_WIDTH, height: PADDLE_HEIGHT },
            items: this.items.map((item) => ({ x: item.x, y: item.y })),
        };
    }

    // Optional hardware settings. Uncomment to change the screen size or speed, or to
    // turn off the BLIT386 splash that plays when you build your game for real.
    // Full detail: docs/basics.md
    //
    // configure() {
    //     return {
    //         displaySize: new Vector2i(320, 240),
    //         targetFPS: 60,
    //         isSplashEnabled: false,
    //     };
    // }

    // Optional hot-reload hook (engine 1.4.0+). The blit386() Vite plugin calls this after a
    // save that re-runs init() (or after a method-only swap). Use the snapshot to keep score
    // and other fields when you tweak init(). Leave it commented until you need it.
    // Full detail: docs/hot-reload.md
    //
    // onHotReload(context) {
    //     // Only restore after a re-init (not after a method-only swap).
    //     if (context.reason !== 'reinit' || !context.snapshot) {
    //         return;
    //     }
    //     if (typeof context.snapshot.score === 'number') {
    //         this.score = context.snapshot.score;
    //     }
    //     if (typeof context.snapshot.lives === 'number') {
    //         this.lives = context.snapshot.lives;
    //     }
    // }
}

// Hand the Game class to BLIT386. It builds one game, runs init() once, then update() and render() about 60 times a second.
bootstrap(Game);

On this page