BLIT386BLIT386

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 via CanvasLayoutStyles (logical size, CSS cap, pixelated scaling). Custom hosts can reuse the same helper when not using bootstrap().

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(), set canvas.tabIndex = 0 and call canvas.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.

TermWhat it isConfigure fieldBT getter
LogicalGame/simulation coordinate space; where render() draws palette-indexed pixelsdisplaySizeBT.displaySize
Drawing bufferGPU/canvas backing-store resolution; upscale target and display-tier post-process resolutiondrawingBufferSizeBT.drawingBufferSize
(derived)Effective drawing-buffer size (drawingBufferSize when set, otherwise logical 1:1)-BT.outputSize
CSS capMaximum on-screen canvas size in CSS pixels (layout scales to the viewport, not beyond this)maxCanvasSize(no getter)
Effect tierPost-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() via mergeHardwareSettings() (also exported).
  • Omit displaySize to inherit the full default resolution and 640×480 output buffer.
  • Include displaySize when you want a custom logical size; optional fields you omit then stay unset (for example no drawingBufferSize means a 1:1 drawing buffer).
FieldTypeDefaultDescription
displaySizeVector2i320×240Logical render resolution
drawingBufferSizeVector2i640×480Drawing buffer size; enables display-tier effects when set
maxCanvasSizeVector2i960×720CSS cap - maximum on-screen canvas size
targetFPSnumber60Fixed update() rate (simulation ticks per second)
backend'webgpu' | 'software''webgpu'Force rendering backend
audioVoicesnumber16Max 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
isDetectingDroppedFramesbooleanfalseLog a console warning on missed vsync
isCapturingPointerScrollbooleanfalseOpt 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
isCapturingKeyboardScrollbooleanfalseOpt into canvas keydown preventDefault for arrow keys, Space, PageUp/PageDown, Home, and End (page scrolls when off)
isWakeLockEnabledbooleanfalseRequest a screen wake lock after init to prevent mobile screen dimming/locking during gameplay (silent no-op if unsupported)
preferredOrientationPreferredOrientation'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
isSplashEnabledbooleanunsetPlay the BLIT386 splash before the game starts. Unset means shown in release builds, hidden in development. See the splash guide
splashColorDarkColor32Color32.blackDark endpoint of the splash's 16-step gray ramp
splashColorLightColor32Color32.whiteLight endpoint of the splash's 16-step gray ramp
isFrameCaptureShortcutEnabledbooleanunsetF9 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
isOverlayEnabledbooleantrueEngine overlay HUD after each render()
isOverlayVisibleAtStartbooleanfalseShow overlay body (metrics/palette/custom rows) on first frame
isOverlayToggleHintVisiblebooleantrueDraw toggle hint icon while overlay body is hidden
isOverlayToggleEnabledbooleantrueEnable Backquote and bottom-left corner toggle input
isOverlayToggleHitDebugVisiblebooleanfalseDraw a 1 px outline of the bottom-left 17×13 toggle hit region (for hit-zone tuning)
isOverlayPaletteEnabledbooleanfalseLive palette swatch grid in the overlay bottom band (opt-in)
overlayPaletteColumnsnumberunsetMax palette swatches per grid row (default: widest fit)
overlayPaletteRowsVisiblenumberunsetMax visible palette grid rows (default: all rows; band height capped)
overlayStyleOverlayStyleunsetOptional bar/text/gap palette indices for overlay
isOverlayTimingChartEnabledbooleanfalseScrolling update/render timing chart between title and metrics rows
overlayTimingChartHeightnumber22Timing chart band height in pixels when the chart is enabled
overlayTimingChartStyleOverlayTimingChartStyleunsetOptional timing chart palette indices (defaults to overlay bar/text)
overlayTimingChartDiagnosticsfalse | 'minimal' | 'rich'unsetRenderer diagnostic visualization on the timing chart ('minimal' when chart enabled and omitted)
isOverlayRendererDiagnosticsBarEnabledbooleanfalseOptional GPU diagnostics text row below frame timing metrics
isOverlayAudioMetersEnabledbooleanfalsePer-bus level bars and voices/steal/drop text readout band
overlayAudioMeterHeightnumber13Audio meter band height in pixels when audio meters are enabled
overlayAudioMeterStyleOverlayAudioMeterStyleunsetOptional audio meter palette indices (defaults to overlay text/gap)
  • displaySize, drawingBufferSize, and maxCanvasSize must be positive whole-number pixel dimensions.
  • Each size is capped at 8192×8192 per axis and 16,777,216 total pixels (4096×4096).
  • Invalid sizes make initialization fail before the engine applies canvas layout, sets canvas backing dimensions, or allocates renderer buffers.
  • BT.init() returns false and 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 maxTextureDimension2D limit.
  • 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
KindBT getterHardwareSettings field
MirrordisplaySize, drawingBufferSize, targetFPSsame names
MirrorrequestedBackendbackend
DerivedoutputSize (drawingBufferSize ?? displaySize)(none)
  • activeBackend is runtime state (what actually started; may differ from requestedBackend after WebGPU fallback). See Resolution model for drawing-buffer vocabulary.
  • isAudioUnlocked is runtime state too: false until a user gesture resumes the audio context, true for the rest of the session afterward. See API: Audio.
  • screenOrientation is runtime state from the Screen Orientation API (null when unavailable). See Screen orientation.
  • isReducedMotionPreferred is runtime state too: resolves prefers-reduced-motion (or a ?reducedmotion / ?noreducedmotion URL override). See Reduced motion.
  • BT.random is runtime state too: a live, always-present Random instance (time-seeded at engine start). Reseed with BT.randomSeed(seed) for reproducible runs, or open the page with ?seed=N (since 1.7.1) - applied in init() before the demo's own init(), in release builds too, so a randomSeed call 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 change and calls optional IBTDemo.onOrientationChange(type) when the demo implements it.
  • Attempts screen.orientation.lock() when HardwareSettings.preferredOrientation is 'landscape' or 'portrait' (default 'any' skips the lock). Lock failures are silent no-ops and never fail init().

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:

GetterWhen setMeaning
BT.requestedBackendAfter hardware settings load (configure() merge + URL override), before or after renderer initBackend the engine will try / tried to start ('webgpu' default when omitted)
BT.activeBackendAfter successful renderer init onlyBackend 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 with defaultConfig().
  • ?backend=software mutates the resolved HardwareSettings.backend before initRenderer() runs (during loadHardwareSettings()). A page opened as /demos/crt-pipboy.html?backend=software therefore reports BT.requestedBackend === 'software' even when configure() 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 init

Dev 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:

  1. globalThis.__BLIT386_DEV__, set at runtime by the blit386/vite plugin's injected snippet. A runtime assignment, not a bundler define, so it works regardless of how the engine itself was built or bundled.
  2. A live Vite HMR context, as a late fallback (see Hot Reload).
  3. 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:

FieldValue
ticksBT.ticks - always present, so an agent can tell whether the game advanced between two calls
backendBT.activeBackend
stateDeep copy of the hook's result, or null when the game has no hook
errorOnly 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 as Vector2i arrive as { x, y }, and functions and undefined fields are dropped.
  • BT.testState() never throws. A throwing hook, a cycle, or a BigInt logs [BT] testState() threw: to the console and comes back as state: null with the message in error.
  • 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 the window.BT global is dev-only (see BootstrapOptions.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 192
fromWhat 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). After BT.ticksReset(), a 'start' seek still counts seconds from the boot run's tick 0 (it replays init()), not from the reset point.
  • A 'start' seek resets the BT.random stream (back to where it was before the boot init(), 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(), then init()), 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 one update(), render(), and the orientation and reduced-motion hooks reach. The constructor must take no arguments, as it does for bootstrap(): a class instance you built with arguments and passed to BT.init(demo, canvas) replays as a default-built copy, so keep per-run setup in init().
  • If the constructor throws or init() fails, renderAt rejects and the previous instance stays active, but the engine stays reset (random stream, palette, camera, ticks) and anything a failed init() 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 its init() 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 Palette you pass to BT.paletteSet() inside init() (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 that init() starts behind the splash are dropped, so start a palette cycle you want to see from update() (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 of BT.timeSeconds seeks 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), and BT.resume() restarts the loop from that tick. Calling renderAt again without resuming is fine. BT.captureFrame() and BT.resume() run in call order with renderAt: each takes effect after every renderAt called before it and before any called after it. So an un-awaited BT.renderAt(5) followed by BT.captureFrame() captures the frame at 5 seconds, and BT.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's render() 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(), and BT.soundPlay calls made by stepped updates are dropped (they return INVALID_SOUND_REF). Music started by init() during a 'start' seek plays from its beginning after resume - realigning it needs music seeking, which has not shipped yet.
  • renderAt rejects before init() 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 of BT.renderAt(3, { from: 'current' })), for an unknown from, for a past target with 'current', and when the re-run init() fails. Overlapping calls run one after another. When a seek fails after it started (for example the re-run init() fails), the loop stays stopped - call BT.renderAt again or BT.resume().
  • On one machine and backend, the frame renderAt(n / BT.targetFPS) produces is byte-identical to the frame the live loop draws at tick n when that live frame also lands exactly on the tick, on WebGPU and software. A seek renders with BT.renderAlpha at 0; a live frame drawn between two ticks can have a larger renderAlpha, so a game that interpolates motion in render() draws those in-between frames differently. Palette effects advance once per fixed update on the tick clock (BT.ticks * 1000 / BT.targetFPS), never performance.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() or Date.now()
    • state render() changes, because a seek calls render() once, at the end (start effects and change state in init() or update())
    • a custom post-process effect that adds up the deltaMs it 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 from BT.timeSeconds, as the built-in effects' time fields expect.

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

SymbolSinceLast changedStatus
bootstrap0.2.01.7.0stable
BootstrapOptions0.2.0–stable
BT.activeBackend1.0.4–stable
BT.displaySize1.0.4–stable
BT.drawingBufferSize1.1.0–stable
BT.init0.1.01.8.0stable
BT.isDevMode1.5.0–stable
BT.isReady1.8.0–stable
BT.isReducedMotionPreferred1.7.0–stable
BT.isSplashVisible1.5.0–stable
BT.outputSize1.0.4–stable
BT.renderAt1.8.0–stable
BT.requestedBackend1.1.0–stable
BT.resume1.8.0–stable
BT.screenOrientation1.3.1–stable
BT.splashState1.5.0–stable
BT.targetFPS1.0.4–stable
BT.testState1.8.0–stable
defaultConfig1.0.3–stable
displayError1.0.3–stable
getCanvas1.0.3–stable
HardwareSettings0.1.01.8.0stable
HotContext1.4.0–stable
HotReloadContext1.4.0–stable
mergeHardwareSettings1.0.5–stable
PreferredOrientation1.3.1–stable
registerHotReload1.4.0–stable
RenderAtFrom1.8.0–stable
RenderAtOptions1.8.0–stable
TestStateSnapshot1.8.0–stable

1.8.0

  • ChangedBT.initA call made while another `init()` is still running resolves to `false` and changes nothing.
  • AddedBT.isReady
  • AddedBT.renderAt
  • AddedBT.resume
  • AddedBT.testState
  • ChangedHardwareSettingsAdded HardwareSettings.keyboardLayout.
  • AddedRenderAtFrom
  • AddedRenderAtOptions
  • AddedTestStateSnapshot

1.7.0 - Fri Sep 11 2026

1.5.0 - Tue Aug 11 2026

1.4.0 - Thu Jul 23 2026

  • AddedHotContext
  • AddedHotReloadContext
  • ChangedbootstrapCalling `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`.
  • AddedregisterHotReload

1.3.1 - Sun Jul 19 2026

1.1.0 - Sat May 30 2026

  • AddedBT.drawingBufferSize
  • AddedBT.requestedBackend

1.0.5 - Thu May 21 2026

  • AddedmergeHardwareSettings

1.0.4 - Sun May 17 2026

  • AddedBT.activeBackend
  • AddedBT.displaySize
  • AddedBT.outputSize
  • AddedBT.targetFPS

1.0.3 - Sat May 16 2026

  • AddeddefaultConfig
  • AddeddisplayError
  • AddedgetCanvas

0.2.0 - Mon Dec 22 2025

  • AddedBootstrapOptions
  • Addedbootstrap

0.1.0 - Sat Dec 13 2025

  • AddedBT.init
  • AddedHardwareSettings

See also

Last updated on October 18, 2026

On this page