Core
Bootstrap, initialization, and default configuration.
The rest of the core API surface lives in dedicated pages: Overlay (HUD configure flags and style),
Game Loop (tick timing, Timer, and the BT.renderAlpha render-interpolation factor),
Camera, Easing, Core Types (Vector2i, Rect2i, Color32),
Audio (bus volume, mute, unlock state), and Browser Support.
Starting a new project?
The quickest path is the scaffolder: npm create blit386@latest my-game (works with npm, pnpm, yarn, or bun).
It generates a ready-to-run Vite project with the engine installed, a starter game, and local docs.
See create-blit386. The rest of this page
documents the bootstrap()
API for hand-wired or existing projects.
Bootstrap
bootstrap Since 0.2.0
BootstrapOptions Since 0.2.0
The bootstrap() function is the recommended entry point. It handles DOM ready, canvas lookup, backend selection
(WebGPU or software fallback), and error display automatically. It returns a Promise<boolean>: true on a successful
cold start or hot swap, false when init fails or a second call is rejected without a hot-reload context.
// One-liner - canvas id defaults to 'blit386-canvas', container to 'canvas-container'
const = await ();
// With options
await (, {
: 'my-canvas',
: 'error-wrapper',
: () => .('started'),
: () => (),
: true, // default true; set false in Electron after DOMContentLoaded
: , // default: BT.isDevMode - window.BT for console debugging
});BootstrapOptions fields:
Prop
Type
exposeGlobal lets you debug from the browser console (window.BT.captureFrame()). Unset, it follows BT.isDevMode -
on in development, off in a consumer's production build. Set true to force it on in release too, or false to disable
it even in development. The assignment only runs when a window is actually present, so importing the engine in Node or
another non-browser context never throws.
Manual utilities (for custom initialization flows):
getCanvas Since 1.0.3
displayError Since 1.0.3
import { , } from 'blit386';
const = ('my-canvas'); // returns null on missing element
('Init Failed', 'WebGPU unavailable.', 'my-container');bootstrap()applies canvas layout CSS custom properties viaCanvasLayoutStyles(logical size, CSS cap, pixelated scaling). Custom hosts can reuse the same helper when not usingbootstrap().
Hot reload
registerHotReload Since 1.4.0
HotContext Since 1.4.0
HotReloadContext Since 1.4.0
Under a Vite dev server with the blit386/vite plugin installed, calling bootstrap() again after the engine is
already initialized - exactly what happens on every hot-reloaded save - routes to a hot swap instead of starting a
second, unstoppable game loop. registerHotReload(hot) registers the active import.meta.hot context so the engine
knows a swap is possible; the plugin injects the call to it automatically into the demo/game's entry module, so you
never call it by hand.
HotContext is the structural type for that hot argument: the subset of Vite's import.meta.hot the engine depends
on (data, on, invalidate, accept). It is kept structural on purpose so the published engine never imports from
the vite package.
Depending on what changed, the swap is a prototype-only method swap, a full re-init, or a full page reload - see
Hot Reload for the three tiers with worked examples. IBTDemo has an optional
onHotReload(context: HotReloadContext) hook for reacting to a swap:
import { type IBTDemo, type HotReloadContext } from 'blit386';
class implements IBTDemo {
(: HotReloadContext): void {
.(`hot reload #${.} (${.})`);
}
async (): <boolean> {
return true;
}
(): void {}
(): void {}
}Prop
Type
onHotReload never fires for a hardware-settings change - that always triggers a full page reload instead. Without a
registered hot-reload context (no blit386/vite plugin, or a second bootstrap() call outside a Vite HMR session), a
second call while already initialized logs an error and returns false. That guard is new in 1.4.0; before then a
second call silently started another unstoppable GameLoop.
Initialization
BT.init Since 0.1.0
const = await .(, ); // low-level init; prefer bootstrap()
.; // Vector2i - configured logical resolution (clone per read)
.; // Vector2i | null - output buffer when set in configure()
.; // Vector2i - effective drawing-buffer size (clone per read)
.; // number - fixed update() rate (simulation), not measured present FPS
.; // 'webgpu' | 'software' | null - resolved request (see below)
.; // 'webgpu' | 'software' | null - backend that actually started
.; // 'landscape-primary' | … | null - Screen Orientation API type
.; // number - in-flight image + audio loads (see API: Assets)BT.init()selects WebGPU or falls back to the Canvas 2D software renderer automatically.- When not using
bootstrap(), setcanvas.tabIndex = 0and callcanvas.focus()so keyboard events reach the canvas.
Resolution model
BT.displaySize Since 1.0.4
BT.drawingBufferSize Since 1.1.0
BT.outputSize Since 1.0.4
BLIT386 tracks several related pixel dimensions. Public configure/getter names (displaySize, drawingBufferSize,
maxCanvasSize) map to the layers below; display-tier is a separate post-process term.
| Term | What it is | Configure field | BT getter |
|---|---|---|---|
| Logical | Game/simulation coordinate space; where render() draws palette-indexed pixels | displaySize | BT.displaySize |
| Drawing buffer | GPU/canvas backing-store resolution; upscale target and display-tier post-process resolution | drawingBufferSize | BT.drawingBufferSize |
| (derived) | Effective drawing-buffer size (drawingBufferSize when set, otherwise logical 1:1) | - | BT.outputSize |
| CSS cap | Maximum on-screen canvas size in CSS pixels (layout scales to the viewport, not beyond this) | maxCanvasSize | (no getter) |
| Effect tier | Post-process chain stage: pixel tier at logical resolution; display tier at drawing buffer | (requires drawing buffer) | (see post-process docs) |
Typical WebGPU flow at default 320×240 logical with 640×480 drawing buffer:
render() @ logical (320×240)
→ pixel-tier effects @ logical
→ palette resolve + upscale @ drawing buffer (640×480) ← BT.outputSize
→ display-tier effects @ drawing buffer
→ swap chain → browser scales canvas (up to CSS cap 960×720)Which size for CRT?
CRT-style effects (scanlines, barrel distortion, RGB mask, bloom) are display-tier. They run at the drawing buffer - use
BT.outputSize, not BT.displaySize. Set drawingBufferSize larger than displaySize (for example 320×240 logical
and 1280×960 buffer) so curvature and scanlines are not quantized onto the logical pixel grid. Display-tier
registration throws when drawingBufferSize is unset. Pixel-native effects (PixelGlitch, PixelMosaic) are
pixel-tier and run at BT.displaySize (logical).
What is `BT.outputSize`?
The effective drawing-buffer width and height in pixels: drawingBufferSize ?? displaySize. Palette resolve/upscale and
the display-tier chain both operate at this size. When drawingBufferSize is omitted, logical and drawing buffer match
(1:1). Each read returns a clone. There is no HardwareSettings.outputSize field - only the runtime getter.
See Post-Process Effects for tier routing and presets.
Capturing at displaySize instead of outputSize
BT.captureFrame() and BT.downloadFrame() match BT.outputSize by default, as described above. Pass
{ size: 'display' } (engine 1.7.1+) to capture at logical BT.displaySize instead, resolved directly from the
palette-indexed scene buffer. That keeps every logical pixel a single PNG pixel for tests, image diffs, and palette
debugging, but it also means the capture excludes display-tier post-process effects (scanlines, vignette, bloom, and
similar) even when drawingBufferSize is set - those effects only exist in the post-upscale buffer this path bypasses.
The dev-mode frame-capture shortcuts (HardwareSettings.isFrameCaptureShortcutEnabled) take the same logical-size path:
F9 copies to the OS clipboard, Shift+F9 downloads a file. Details and the size comparison table:
Rendering.
Hardware settings
HardwareSettings Since 0.1.0
Resolved after configure(); the hook may return a partial object.
configure()may return only the fields you want to override.- The engine merges them with
defaultConfig()viamergeHardwareSettings()(also exported). - Omit
displaySizeto inherit the full default resolution and640×480output buffer. - Include
displaySizewhen you want a custom logical size; optional fields you omit then stay unset (for example nodrawingBufferSizemeans a 1:1 drawing buffer).
| Field | Type | Default | Description |
|---|---|---|---|
displaySize | Vector2i | 320×240 | Logical render resolution |
drawingBufferSize | Vector2i | 640×480 | Drawing buffer size; enables display-tier effects when set |
maxCanvasSize | Vector2i | 960×720 | CSS cap - maximum on-screen canvas size |
targetFPS | number | 60 | Fixed update() rate (simulation ticks per second) |
backend | 'webgpu' | 'software' | 'webgpu' | Force rendering backend |
audioVoices | number | 16 | Max simultaneous SFX voices, 1-64; sizes the fixed SFX voice pool (when full, a new sound steals an eligible voice, or is dropped when no eligible voice exists) |
outputUpscaleFilter | 'nearest' | 'linear' | 'nearest' | Upscale filter |
isDetectingDroppedFrames | boolean | false | Log a console warning on missed vsync |
isCapturingPointerScroll | boolean | false | Opt into canvas wheel preventDefault and BT.pointerScrollDelta; also gates touch-action (none when capturing, pan-y otherwise) so touch tap-hold-scroll follows the same opt-in (page scrolls over the canvas when off). The overlay palette band can also force capture (and touch-action: none) while a pointer is over it, even when this flag is false |
isCapturingKeyboardScroll | boolean | false | Opt into canvas keydown preventDefault for arrow keys, Space, PageUp/PageDown, Home, and End (page scrolls when off) |
isWakeLockEnabled | boolean | false | Request a screen wake lock after init to prevent mobile screen dimming/locking during gameplay (silent no-op if unsupported) |
preferredOrientation | PreferredOrientation | 'any' | Attempt screen.orientation.lock() after init ('landscape' / 'portrait'); 'any' skips the lock (silent no-op if unsupported) |
keyboardLayout | 'versus' | 'single' | 'versus' | Built-in keyboard map: 'versus' gives player 0 WASD and player 1 the arrows; 'single' gives player 0 WASD and the arrows and moves player 1 to IJKL, and turns on isCapturingKeyboardScroll unless set explicitly. See the input guide |
isSplashEnabled | boolean | unset | Play the BLIT386 splash before the game starts. Unset means shown in release builds, hidden in development. See the splash guide |
splashColorDark | Color32 | Color32.black | Dark endpoint of the splash's 16-step gray ramp |
splashColorLight | Color32 | Color32.white | Light endpoint of the splash's 16-step gray ramp |
isFrameCaptureShortcutEnabled | boolean | unset | F9 copies the current frame to the clipboard, and Shift+F9 downloads it as a timestamped PNG - both at logical BT.displaySize rather than BT.outputSize (like BT.captureFrame({ size: 'display' })) and without display-tier post-process effects, see the callout above. Unset means enabled in development builds, disabled in release. Set true to keep both in a production build, or false to free up F9 and Shift+F9 |
isOverlayEnabled | boolean | true | Engine overlay HUD after each render() |
isOverlayVisibleAtStart | boolean | false | Show overlay body (metrics/palette/custom rows) on first frame |
isOverlayToggleHintVisible | boolean | true | Draw toggle hint icon while overlay body is hidden |
isOverlayToggleEnabled | boolean | true | Enable Backquote and bottom-left corner toggle input |
isOverlayToggleHitDebugVisible | boolean | false | Draw a 1 px outline of the bottom-left 17×13 toggle hit region (for hit-zone tuning) |
isOverlayPaletteEnabled | boolean | false | Live palette swatch grid in the overlay bottom band (opt-in) |
overlayPaletteColumns | number | unset | Max palette swatches per grid row (default: widest fit) |
overlayPaletteRowsVisible | number | unset | Max visible palette grid rows (default: all rows; band height capped) |
overlayStyle | OverlayStyle | unset | Optional bar/text/gap palette indices for overlay |
isOverlayTimingChartEnabled | boolean | false | Scrolling update/render timing chart between title and metrics rows |
overlayTimingChartHeight | number | 22 | Timing chart band height in pixels when the chart is enabled |
overlayTimingChartStyle | OverlayTimingChartStyle | unset | Optional timing chart palette indices (defaults to overlay bar/text) |
overlayTimingChartDiagnostics | false | 'minimal' | 'rich' | unset | Renderer diagnostic visualization on the timing chart ('minimal' when chart enabled and omitted) |
isOverlayRendererDiagnosticsBarEnabled | boolean | false | Optional GPU diagnostics text row below frame timing metrics |
isOverlayAudioMetersEnabled | boolean | false | Per-bus level bars and voices/steal/drop text readout band |
overlayAudioMeterHeight | number | 13 | Audio meter band height in pixels when audio meters are enabled |
overlayAudioMeterStyle | OverlayAudioMeterStyle | unset | Optional audio meter palette indices (defaults to overlay text/gap) |
displaySize,drawingBufferSize, andmaxCanvasSizemust be positive whole-number pixel dimensions.- Each size is capped at
8192×8192per axis and16,777,216total pixels (4096×4096). - Invalid sizes make initialization fail before the engine applies canvas layout, sets canvas backing dimensions, or allocates renderer buffers.
BT.init()returnsfalseand logs a specific[BT]message to the browser console (press F12); the on-canvas bootstrap error stays a generic init failure message.- In WebGPU mode, the requested logical and output sizes must also fit the active adapter/device
maxTextureDimension2Dlimit. - GPU limit failures do not fall back to the software renderer.
The overlay-related fields above (isOverlay*, overlay*) are documented in detail in Overlay.
Getters vs. configuration fields
BT.targetFPS Since 1.0.4
| Kind | BT getter | HardwareSettings field |
|---|---|---|
| Mirror | displaySize, drawingBufferSize, targetFPS | same names |
| Mirror | requestedBackend | backend |
| Derived | outputSize (drawingBufferSize ?? displaySize) | (none) |
activeBackendis runtime state (what actually started; may differ fromrequestedBackendafter WebGPU fallback). See Resolution model for drawing-buffer vocabulary.isAudioUnlockedis runtime state too:falseuntil a user gesture resumes the audio context,truefor the rest of the session afterward. See API: Audio.screenOrientationis runtime state from the Screen Orientation API (nullwhen unavailable). See Screen orientation.isReducedMotionPreferredis runtime state too: resolvesprefers-reduced-motion(or a?reducedmotion/?noreducedmotionURL override). See Reduced motion.BT.randomis runtime state too: a live, always-presentRandominstance (time-seeded at engine start). Reseed withBT.randomSeed(seed)for reproducible runs, or open the page with?seed=N(since 1.7.1) - applied ininit()before the demo's owninit(), in release builds too, so arandomSeedcall there still wins. See API: Random.
Screen orientation
BT.screenOrientation Since 1.3.1
PreferredOrientation Since 1.3.1
BT.screenOrientation reads the current screen.orientation.type string (for example 'landscape-primary' or
'portrait-secondary'), or null when the API is missing. After a successful init(), the engine also:
- Listens for orientation
changeand calls optionalIBTDemo.onOrientationChange(type)when the demo implements it. - Attempts
screen.orientation.lock()whenHardwareSettings.preferredOrientationis'landscape'or'portrait'(default'any'skips the lock). Lock failures are silent no-ops and never failinit().
Showing a "please rotate your device" prompt is a demo concern - the engine only supplies the getter, the change hook, and the optional lock. Browser support details live in Screen orientation.
import { , type HardwareSettings, type IBTDemo } from 'blit386';
class implements IBTDemo {
(): <HardwareSettings> {
return { : 'landscape' };
}
(: string): void {
.('orientation:', , .);
}
async (): <boolean> {
return true;
}
(): void {}
(): void {}
}Reduced motion
BT.isReducedMotionPreferred Since 1.7.0
BT.isReducedMotionPreferred reads the browser's prefers-reduced-motion setting. After a successful init(), the
engine also listens for changes and calls optional IBTDemo.onReducedMotionChange(prefersReduced) when the demo
implements it - the preference can change while your game is running, the same way orientation can.
Reducing your own animation fidelity, screen-shake, or particle counts is a demo concern - the engine only supplies the getter and the change hook. The built-in splash does respect it; see The splash and reduced motion.
import { , type IBTDemo } from 'blit386';
class implements IBTDemo {
(: boolean): void {
.('reduced motion:', , .);
}
async (): <boolean> {
return true;
}
(): void {}
(): void {}
}?reducedmotion and ?noreducedmotion are valueless URL flags for testing either state without changing OS settings or
devtools media emulation; ?noreducedmotion wins when both are present.
Requested vs. active backend
BT.requestedBackend Since 1.1.0
BT.activeBackend Since 1.0.4
Two getters disambiguate what you asked for from what is running:
| Getter | When set | Meaning |
|---|---|---|
BT.requestedBackend | After hardware settings load (configure() merge + URL override), before or after renderer init | Backend the engine will try / tried to start ('webgpu' default when omitted) |
BT.activeBackend | After successful renderer init only | Backend that actually started (null before init or on init failure) |
configure().backend and BT.requestedBackend both describe the request, but only the getter reflects post-merge
resolution:
demo.configure()may return a partial object; the engine merges it withdefaultConfig().?backend=softwaremutates the resolvedHardwareSettings.backendbeforeinitRenderer()runs (duringloadHardwareSettings()). A page opened as/demos/crt-pipboy.html?backend=softwaretherefore reportsBT.requestedBackend === 'software'even whenconfigure()asked for WebGPU.
Fallback: When requestedBackend is 'webgpu' (explicit or default) and WebGPU init fails, the engine logs a warning
and starts the software renderer. Then activeBackend === 'software' while requestedBackend stays 'webgpu'.
The WebGPU renderer module is loaded lazily during init (only after a successful adapter/device setup). Browsers without WebGPU globals (for example Firefox on Linux without Nightly) can therefore start the software backend or auto-fallback without hitting module-load errors from WebGPU-only code.
Runtime checks (post-process, capture, etc.)
Use activeBackend, not requestedBackend:
// Correct: gate on the backend that actually started; BT.effectAdd takes one effect
if (. === 'webgpu') {
for (const of ..()) {
.();
}
}
// Misleading after fallback: requestedBackend may still be 'webgpu'
if (. === 'webgpu') {
for (const of ..()) {
.(); // throws once activeBackend is software
}
}Forcing software up front avoids the fallback path entirely:
function (): <HardwareSettings> {
return { : 'software' /* ... */ };
}
// requestedBackend === activeBackend === 'software' after initDev vs. release mode
BT.isDevMode Since 1.5.0
BT.isDevMode answers one question: is this a development build. Games and demos can gate debug HUDs, cheat keys,
verbose logging, or test fixtures on it instead of inventing a build-mode signal of their own.
import { , type IBTDemo } from 'blit386';
class implements IBTDemo {
async (): <boolean> {
return true;
}
(): void {
if (.) {
.('dev build - verbose logging enabled');
}
}
(): void {}
}Resolution order, first match wins:
globalThis.__BLIT386_DEV__, set at runtime by theblit386/viteplugin's injected snippet. A runtime assignment, not a bundler define, so it works regardless of how the engine itself was built or bundled.- A live Vite HMR context, as a late fallback (see Hot Reload).
- Otherwise, release.
The underlying resolver also accepts an explicit override that always wins over both signals above; nothing in the
public BT surface offers one today, so this only matters if you call the internal resolver directly.
Neither import.meta.env.DEV nor a bundler define can back this getter: the engine ships both ESM and CJS builds and
is pre-built into dist before a consumer's bundler ever sees it, so a define declared in the consumer's config does
not reliably reach a pre-bundled dependency.
This is DX gating, not DRM
Any consumer can flip globalThis.__BLIT386_DEV__ by hand. BT.isDevMode exists to make ordinary dev-vs-release
decisions easy, not to make release builds tamper-proof.
Read it from update()/render(), not module scope
The blit386/vite plugin's snippet is appended after the rest of the entry module, so it runs after that module's own
top-level code but before update()/render() ever run. A module-scope BT.isDevMode read (for example a
const isDev = BT.isDevMode; at the top of a file the entry module imports) can observe false even in a dev build.
Read it inside a lifecycle method instead.
A consumer who skips the blit386/vite plugin reads as release everywhere the plugin's marker would otherwise apply -
see The blit386/vite plugin for what installing it actually does.
Test state for agent play-testing
BT.testState Since 1.8.0
TestStateSnapshot Since 1.8.0
An agent play-testing a game in a browser (a built-in browser pane or a CDP script) needs the game's state as JSON, not
guessed from pixels. Give the game class an optional testState() hook that returns plain JSON, and read it with
BT.testState(). In dev builds BT is already exposed as window.BT, so the agent's evaluate call is
window.BT.testState() - no per-game global and no declare global block.
import { , type IBTDemo, } from 'blit386';
class implements IBTDemo {
private = 0;
private = 3;
private readonly = new (160, 200);
async (): <boolean> {
return true;
}
(): void {}
(): void {}
(): unknown {
return { : this., : this., : this. };
}
}
// From the browser console or an agent's evaluate call (dev build):
// window.BT.testState()
// -> { ticks: 812, backend: 'webgpu', state: { score: 0, lives: 3, player: { x: 160, y: 200 } } }
const = .();BT.testState() returns a TestStateSnapshot envelope:
| Field | Value |
|---|---|
ticks | BT.ticks - always present, so an agent can tell whether the game advanced between two calls |
backend | BT.activeBackend |
state | Deep copy of the hook's result, or null when the game has no hook |
error | Only when the readout failed: the message, with state: null |
- The hook is looked up on the running game instance on every call, so it keeps working after a
'reinit'hot reload swaps in a new instance. - The result goes through
JSON.parse(JSON.stringify(...)). Callers get plain data they cannot use to mutate live game objects; class instances such asVector2iarrive as{ x, y }, and functions andundefinedfields are dropped. BT.testState()never throws. A throwing hook, a cycle, or aBigIntlogs[BT] testState() threw:to the console and comes back asstate: nullwith the message inerror.- Synthetic pointer events dispatched on the canvas need a
pointerType('mouse','touch', or'pen'); an empty one is treated as the mouse, and touch or pen contacts are only tracked when the event says so. - The engine never calls
testState()on its own, so the hook costs nothing unless something reads it.BT.testState()also exists in release builds; only thewindow.BTglobal is dev-only (seeBootstrapOptions.exposeGlobal).
Engine readiness
BT.isReady Since 1.8.0
BT.isReady is true only once BT.init() (or bootstrap()) has resolved and the game loop exists - the same moment
BT.renderAt, BT.captureFrame, and BT.testState become safe to call. It stays false before init, while the splash
is still covering the screen (the loop is created only after the splash handoff), and after a failed init. A parked seek
does not flip it back to false: the loop object is still there.
A second BT.init() call made while one is still in flight is rejected (it resolves to false and changes nothing),
and BT.isReady stays false for the whole duration of any init(), including a re-init after an earlier success.
Poll it from an external driver that loads the page and needs to wait before seeking or reading state. Prefer it over
matching the error text from a premature renderAt.
while (!.) {
await new (() => (, 16));
}
await .(3.2);Seeking a frame with renderAt
BT.renderAt Since 1.8.0
BT.resume Since 1.8.0
RenderAtOptions Since 1.8.0
RenderAtFrom Since 1.8.0
BT.renderAt(seconds) jumps the fixed-step clock to a time and renders exactly the frame at that tick - no
requestAnimationFrame, so it works in hidden tabs and headless browsers. It is what a screenshot tool uses to capture
exactly second 3.2, and how a sync editor scrubs a timeline.
await .(3.2); // tick 192 at 60 FPS
const = await .({ : 'display' }); // the seeked frame
.(); // keep playing from tick 192from | What happens |
|---|---|
'start' (default) | Stops the loop, resets engine-owned state, builds a fresh instance of your game class and runs its init(), steps update() from tick 0 to the target, renders once |
'current' | Stops the loop and steps forward from the current tick; rejects a target in the past |
- The target tick is
Math.round(seconds * BT.targetFPS). AfterBT.ticksReset(), a'start'seek still countssecondsfrom the boot run's tick 0 (it replaysinit()), not from the reset point. - A
'start'seek resets theBT.randomstream (back to where it was before the bootinit(), so a time-seeded or?seed=stream replays identically), palette effects, post-process effects, camera, pending input edges, and ticks. - A
'start'seek replays into a new instance of your game class (new YourGame(), theninit()), so every field starts from its initializer exactly as in the boot run - a counter, a clock, or an array the old run filled cannot carry over. The new instance becomes the oneupdate(),render(), and the orientation and reduced-motion hooks reach. The constructor must take no arguments, as it does forbootstrap(): a class instance you built with arguments and passed toBT.init(demo, canvas)replays as a default-built copy, so keep per-run setup ininit(). - If the constructor throws or
init()fails,renderAtrejects and the previous instance stays active, but the engine stays reset (random stream, palette, camera, ticks) and anything a failedinit()already did to the engine stays applied, except palette effects, which are cleared - seek again or reload the page. - An object literal passed to
BT.init(demo, canvas)has no class to build from, so itsinit()re-runs on the same object and must reset every field itself. - State outside the instance is still yours to reset: module-level variables, objects shared between runs, DOM
listeners, and the palette. A seek does not restore slot colors, so build the
Paletteyou pass toBT.paletteSet()insideinit()(or reset its slots there). Reusing one object from a module constant carries over whatever a palette cycle already rotated. The splash and its fade-in are not replayed. As in the live run, palette effects thatinit()starts behind the splash are dropped, so start a palette cycle you want to see fromupdate()(for example on tick 0) when the splash is on. - A seek costs one
update()per tick: three minutes at 60 FPS is 10,800 calls. A game whose animation is a function ofBT.timeSecondsseeks instantly; heavy per-tick simulation seeks slowly by design. For an export that renders many frames in order, seek once with'start', then step forward with'current'- repeated'start'seeks replay from tick 0 each time. - The loop stays stopped after a seek.
BT.captureFrame()re-renders the seeked frame to capture it (no clock moves), andBT.resume()restarts the loop from that tick. CallingrenderAtagain without resuming is fine.BT.captureFrame()andBT.resume()run in call order withrenderAt: each takes effect after everyrenderAtcalled before it and before any called after it. So an un-awaitedBT.renderAt(5)followed byBT.captureFrame()captures the frame at 5 seconds, andBT.renderAt(1); BT.resume(); BT.renderAt(2)ends stopped on second 2.BT.resume()returns a promise that settles once the loop has restarted; awaiting it is optional. If the game'srender()throws while the seeked frame is re-rendered for a capture,BT.captureFrame()rejects with that error. - The audio context is suspended during the seek and until
BT.resume(), andBT.soundPlaycalls made by stepped updates are dropped (they returnINVALID_SOUND_REF). Music started byinit()during a'start'seek plays from its beginning after resume - realigning it needs music seeking, which has not shipped yet. renderAtrejects beforeinit()has finished, while the splash is up, for a negative, non-finite, or too-large time, for options that are not an object (BT.renderAt(3, 'current')instead ofBT.renderAt(3, { from: 'current' })), for an unknownfrom, for a past target with'current', and when the re-runinit()fails. Overlapping calls run one after another. When a seek fails after it started (for example the re-runinit()fails), the loop stays stopped - callBT.renderAtagain orBT.resume().- On one machine and backend, the frame
renderAt(n / BT.targetFPS)produces is byte-identical to the frame the live loop draws at ticknwhen that live frame also lands exactly on the tick, on WebGPU and software. A seek renders withBT.renderAlphaat0; a live frame drawn between two ticks can have a largerrenderAlpha, so a game that interpolates motion inrender()draws those in-between frames differently. Palette effects advance once per fixed update on the tick clock (BT.ticks * 1000 / BT.targetFPS), neverperformance.now(), so a seek runs exactly the palette updates live play ran. Three things are outside the guarantee:- anything your own code reads from
performance.now()orDate.now() - state
render()changes, because a seek callsrender()once, at the end (start effects and change state ininit()orupdate()) - a custom post-process effect that adds up the
deltaMsit receives - it gets one delta per rendered frame live and one combined delta in a seek, equal only up to float rounding. Drive its time-based uniforms fromBT.timeSeconds, as the built-in effects'timefields expect.
- anything your own code reads from
Splash state
BT.isSplashVisible Since 1.5.0
BT.isSplashVisible is the one-term query game code should use: true while the BLIT386 splash is on screen, false
otherwise. The useful thing it buys an init() is knowing that something is covering for it, so slow optional work can
happen while the splash holds rather than after the first frame.
async function (): <boolean> {
if (.) {
// Something is covering for us - use the time to preload extras.
await ();
}
return true;
}BT.splashState Since 1.5.0
BT.splashState is the raw lifecycle state: 'disabled' | 'fadingIn' | 'shown' | 'fadingOut' | 'done'. Both terminal
values mean the same thing to a consumer - not on screen, never will be again - so code written as
state !== 'disabled' && state !== 'done' is really asking isSplashVisible. Reach for the raw state only when the
distinction genuinely matters, such as debugging or the engine overlay.
Which states game code can actually see depends on whether the splash is playing. With it disabled, 'disabled' is the
only value it will ever read. With it enabled, an init() observes 'fadingIn' and - if it stays alive long enough,
which an await makes easy - 'shown'; its first update() after handoff reads 'done'. 'fadingOut' is engine
internal: update() and render() are suspended for the splash's whole duration, so nothing outside the engine runs
while it is the current state.
One error-handling note: ordinary initialization failures make BT.init() resolve to false, but a splash frame that
throws rejects the returned promise instead - nothing else can settle it, and reporting false would hide a renderer
fault. bootstrap() catches it and routes it to onError like any other init error.
Full behavior - the three gating layers, the loading-screen hold, the palette handoff, and the swallowed skip - is in the splash guide.
Default configuration
defaultConfig Since 1.0.3
mergeHardwareSettings Since 1.0.5
Import mergeHardwareSettings when building custom configure flows outside bootstrap() - it merges its argument with
defaultConfig() internally, so a partial override is enough to get a full settings object back:
import { } from 'blit386';
const = ({ : 30 });defaultConfig() returns a full HardwareSettings object (320×240 logical, 640×480 drawing buffer, overlay
enabled, WebGPU backend, 16 SFX voices, and other defaults documented in the table above).
Putting it together
API history
| Symbol | Since | Last changed | Status |
|---|---|---|---|
bootstrap | 0.2.0 | 1.7.0 | stable |
BootstrapOptions | 0.2.0 | – | stable |
BT.activeBackend | 1.0.4 | – | stable |
BT.displaySize | 1.0.4 | – | stable |
BT.drawingBufferSize | 1.1.0 | – | stable |
BT.init | 0.1.0 | 1.8.0 | stable |
BT.isDevMode | 1.5.0 | – | stable |
BT.isReady | 1.8.0 | – | stable |
BT.isReducedMotionPreferred | 1.7.0 | – | stable |
BT.isSplashVisible | 1.5.0 | – | stable |
BT.outputSize | 1.0.4 | – | stable |
BT.renderAt | 1.8.0 | – | stable |
BT.requestedBackend | 1.1.0 | – | stable |
BT.resume | 1.8.0 | – | stable |
BT.screenOrientation | 1.3.1 | – | stable |
BT.splashState | 1.5.0 | – | stable |
BT.targetFPS | 1.0.4 | – | stable |
BT.testState | 1.8.0 | – | stable |
defaultConfig | 1.0.3 | – | stable |
displayError | 1.0.3 | – | stable |
getCanvas | 1.0.3 | – | stable |
HardwareSettings | 0.1.0 | 1.8.0 | stable |
HotContext | 1.4.0 | – | stable |
HotReloadContext | 1.4.0 | – | stable |
mergeHardwareSettings | 1.0.5 | – | stable |
PreferredOrientation | 1.3.1 | – | stable |
registerHotReload | 1.4.0 | – | stable |
RenderAtFrom | 1.8.0 | – | stable |
RenderAtOptions | 1.8.0 | – | stable |
TestStateSnapshot | 1.8.0 | – | stable |
1.8.0
- Changed
BT.initA call made while another `init()` is still running resolves to `false` and changes nothing. - Added
BT.isReady - Added
BT.renderAt - Added
BT.resume - Added
BT.testState - Changed
HardwareSettingsAdded HardwareSettings.keyboardLayout. - Added
RenderAtFrom - Added
RenderAtOptions - Added
TestStateSnapshot
1.7.0 - Fri Sep 11 2026
- Added
BT.isReducedMotionPreferred - Changed
HardwareSettingsAdded HardwareSettings.isFrameCaptureShortcutEnabled. - Changed
bootstrapExposes `BT` on `window.BT` after bootstrap finishes, gated by BootstrapOptions.exposeGlobal (default: BT.isDevMode).
1.5.0 - Tue Aug 11 2026
- Added
BT.isDevMode - Added
BT.isSplashVisible - Added
BT.splashState - Changed
HardwareSettingsAdded HardwareSettings.isSplashEnabled. - Changed
HardwareSettingsAdded HardwareSettings.splashColorDark. - Changed
HardwareSettingsAdded HardwareSettings.splashColorLight.
1.4.0 - Thu Jul 23 2026
- Added
HotContext - Added
HotReloadContext - Changed
bootstrapCalling `bootstrap()` again while already initialized now routes to a hot swap (via registerHotReload) when a Vite HMR context is registered, or logs a double-bootstrap guard and returns `false` otherwise - previously it silently started a second, unstoppable `GameLoop`. - Added
registerHotReload
1.3.1 - Sun Jul 19 2026
- Added
BT.screenOrientation - Changed
HardwareSettingsAdded HardwareSettings.isOverlayToggleHitDebugVisible. - Changed
HardwareSettingsShrink bottom-left overlay toggle hit region from 48x48 to 17x13. - Changed
HardwareSettingsAdded HardwareSettings.isCapturingPointerScroll. - Changed
HardwareSettingsAdded HardwareSettings.isCapturingKeyboardScroll. - Changed
HardwareSettingsAdded HardwareSettings.isWakeLockEnabled. - Changed
HardwareSettingsAdded HardwareSettings.preferredOrientation. - Added
PreferredOrientation
1.1.0 - Sat May 30 2026
- Added
BT.drawingBufferSize - Added
BT.requestedBackend
1.0.5 - Thu May 21 2026
- Added
mergeHardwareSettings
1.0.4 - Sun May 17 2026
- Added
BT.activeBackend - Added
BT.displaySize - Added
BT.outputSize - Added
BT.targetFPS
1.0.3 - Sat May 16 2026
- Added
defaultConfig - Added
displayError - Added
getCanvas
0.2.0 - Mon Dec 22 2025
- Added
BootstrapOptions - Added
bootstrap
0.1.0 - Sat Dec 13 2025
- Added
BT.init - Added
HardwareSettings
See also
API: Overlay
API: Game Loop
API: Camera
API: Easing
API: Core Types
API: Browser Support
API: Rendering
API: Palette
API: Assets
API: Audio
Overlay Guide
Post-Process Effects
Hot Reload Guide
Deprecation Timeline
Last updated on October 18, 2026