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
{
"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
<!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
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
// 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);