Basics: the game loop
Every BLIT386 game is one class with up to four methods. You hand the class to `bootstrap()` and the engine takes care of the rest.
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.
Every BLIT386 game is one class with up to four methods. You hand the class to bootstrap() and the engine takes care
of the rest.
import { bootstrap, BT, Vector2i } from 'blit386';
class Game {
configure() {
// Optional. Omit it to get a 320x240 screen at 60 frames per second.
// Return settings to change the size or speed:
return {
displaySize: new Vector2i(320, 240), // the pixel grid you draw on
targetFPS: 60, // how many times update/render run per second
// preferredOrientation: 'landscape', // optional; ask phones to stay landscape
};
}
async init() {
// Runs once, at the start. Set up colors and load anything you need.
// Return true when it worked.
return true;
}
// Optional. Called when the phone rotates (type is a string like 'landscape-primary').
// onOrientationChange(type) {}
update() {
// Runs ~60 times a second, before render(). Read input and change your game's state here.
// Do NOT draw here.
}
render() {
// Runs ~60 times a second, after update(). Draw the current picture here.
// Do NOT change game logic here.
}
}
bootstrap(Game);Why update and render are separate
update()is the "thinking" step: where is the player, did they press a key, did two things collide.render()is the "drawing" step: put the current state on screen.
Keeping them apart keeps games predictable. Decide things in update(), draw them in render().
Telling time
The engine gives you a few read-only values (they are properties, so no parentheses):
BT.ticks- how many update steps have happened since the start (a steadily rising whole number). Great for timers:if (BT.ticks % 30 === 0) { ... }does something twice a second.BT.targetFPS- the frames-per-second you asked for (default 60).BT.deltaSeconds- how much time one step represents, in seconds. Use it for smooth motion if you prefer speed-per-second over speed-per-step.BT.timeSeconds- the same clock in seconds (BT.ticks * BT.deltaSeconds).
Animate from these, never from performance.now() or Date.now(). The engine's clock is the step counter, so
BT.renderAt(seconds) (engine 1.8.0+) can jump to any moment and draw exactly what the game would show there - but only
for motion that reads BT.timeSeconds or BT.ticks. Anything driven by the wall clock jumps around when you seek and
never matches a screenshot. From outside the game (a screenshot tool or harness), wait for BT.isReady first - it flips
true only once init() has finished and the loop exists. Inside update() / render() you are already past that.
For the same reason, change game state and start palette effects in init() or update(), never in render(): a seek
runs update() for every step but render() only once, at the end. A seek always draws exactly on a step, with
BT.renderAlpha at 0 (see "Drawing between two steps" below), so it matches a live frame that also landed exactly on
that step - not one the game drew partway between two steps.
Phones and screen orientation
BT.screenOrientation is the current orientation type from the browser (for example 'landscape-primary'), or null
when the browser does not report one. Set preferredOrientation: 'landscape' or 'portrait' in configure() if your
game assumes one layout - the engine asks the browser to lock after start when it can (often Android Chrome; not iOS
Safari). Optional onOrientationChange(type) on your game class runs when the player rotates the device. Drawing a
"please rotate" message is still your code; the engine only tells you the orientation.
Respecting reduced motion (blit386 1.7.0+)
Some people turn on a system setting that asks apps for less movement - because heavy motion makes them dizzy or unwell.
BT.isReducedMotionPreferred is true when they have (a property, no parentheses):
async init() {
this.shakeAmount = BT.isReducedMotionPreferred ? 0 : 4;
return true;
}
// Optional. The engine calls this if the setting changes while the game is running.
onReducedMotionChange(prefersReduced) {
this.shakeAmount = prefersReduced ? 0 : 4;
}Read it once in init() for the starting value, and add onReducedMotionChange(prefersReduced) to your game class if
you want to react mid-session. The BLIT386 splash already tones itself down on its own, but the engine never changes
your drawing for you. Screen shake, flashing, and long swooping transitions are the things worth toning down; the game
itself moving is fine.
Drawing between two steps
update() runs at a fixed speed (60 times a second), but the screen often draws faster than that - 120 or 144 times a
second on a modern monitor. That means some drawn frames land in between two thinking steps.
BT.renderAlpha tells you where you landed: 0 is "exactly on the previous step", 1 is "exactly on the current
step", 0.5 is "halfway between them". If you ignore it and just draw the current position, motion looks slightly
stepped. If you use it, motion looks smooth.
To use it, remember where a thing was before you move it:
import { BT, Rect2i, Vector2i } from 'blit386';
update() {
this.prevPos = this.pos.clone(); // remember, BEFORE moving
this.pos = this.pos.add(this.vel);
}
render() {
const drawPos = Vector2i.lerp(this.prevPos, this.pos, BT.renderAlpha);
BT.drawRectFill(new Rect2i(drawPos.x, drawPos.y, 8, 8), 2);
}Do this for things that move. Do not do it for the HUD or the score - those should sit still.
init returns a promise
init() is async, which means it can wait for things to load. Anything that loads (a font, a sprite sheet) gives back
a promise, so you must await it:
async init() {
this.font = await SomeLoader.load('...'); // await is required
return true;
}Forgetting await is the single most common beginner mistake. If something you loaded is undefined, check for a
missing await first.
Call bootstrap(Game) once. While init() is still running, BT.isReady stays false and a second start is refused
(engine 1.8.0+: an overlapping BT.init resolves to false, a second bootstrap() logs an error), so a stray double
call cannot start two games - but it is still a bug worth removing.
Waiting for assets (a loading screen)
BT.loadingAssetsCount is how many image and audio loads are still in flight. It is a property (no parentheses). To
draw a "Loading…" message, return from init() before every load finishes (start loads in a helper you do not await
from init()), then poll the counter in update() / render():
if (BT.loadingAssetsCount > 0) {
BT.systemPrint(new Vector2i(8, 8), 1, 'Loading...');
return;
}If you await every load inside init(), the loop has not started yet, so there is nothing to draw on - that is fine
for small games. Per-sheet hot-reload replacements also expose sheet.status ('loading' | 'ready' | 'failed') and a
coarse sheet.progress (0 or 1.0). Prefer BT.loadingAssetsCount for one engine-wide signal.
The splash
A short BLIT386 splash plays before your game starts. A real build shows it by default; running npx blit run (or
npm run dev) is a development build, which skips it by default. To check it, build and preview:
npm run build
npm run previewWhile the splash is up, your init() is already running - the splash doubles as a loading screen and holds until
init() finishes, however long that takes. So if the splash is playing, awaiting your loads inside init() costs the
player nothing. Any key, click, or tap skips the fade-in and the waiting, but it still waits for your init() to
finish, and the fade into your game's colors always plays in full. That press is swallowed, so your first update()
does not see it.
One thing to know: the splash owns the screen colors while it plays. Your BT.paletteSet() inside init() is held and
applied when the splash finishes, fading up out of black. Palette effects you start inside init() are dropped, so
start those from your first update() instead.
Turn it off in configure():
configure() {
return { isSplashEnabled: false };
}Or skip it once by adding ?nosplash to the URL. ?splash forces it on, which is how you check it in a dev build.
(?seed=N is the other URL parameter worth knowing: it seeds BT.random so a run repeats - see docs/random.md.)
Keep playing while you edit
New projects include the blit386 Vite plugin, so most saves keep the running game alive. Optional
onHotReload(context) on your game class can restore fields after an init() edit. Full detail: docs/hot-reload.md.
Next: docs/drawing.md to put things on screen, docs/input.md to react to the player, docs/palette.md for colors,
docs/random.md for dice rolls and generated worlds.
Sound and music
BLIT386 can make noise. It plays sound files, it loops background music, and - the fun part - it can build little retro sounds out of nothing, so you can...
Drawing
All drawing happens inside `render()`. You draw with palette slot numbers (see `docs/palette.md`); a `Color32` also works anywhere a slot number does.