/*
Image Output: save the frame as a PNG with BT.downloadFrame() or BT.captureFrame().
@description Capture frames as PNG with BT.downloadFrame and BT.captureFrame, at output or display size.
@ogScale integer
Part of the BLIT386 demo series.
Prerequisites:
- Basics: https://demos.blit386.dev/basics
- Primitives: https://demos.blit386.dev/primitives
Live version: https://demos.blit386.dev/image-output
Guides:
- Overlay: https://blit386.dev/docs/guides/overlay#visibility-model
Reference:
- Rendering: https://blit386.dev/docs/api/rendering#frame-capture
- Core: https://blit386.dev/docs/api/core#resolution-model
- Core: https://blit386.dev/docs/api/core#dev-vs-release-mode
---
WHAT YOU WILL SEE
-----------------
A test pattern that is easy to check in an image viewer: six horizontal stripes, each 40
pixels tall, slowly cycling through the rainbow; a grid of single white dots every 20
pixels; a white border around the whole screen; and a small white crosshair in the exact
center. A UI kit panel titled "Image Output" sits in the top-left corner with three
buttons - "Save PNG (S)", "Size: output (Z)", and "Capture Blob (B)" - a status row, and a
FRAME counter that goes up by one every tick.
WHAT YOU WILL LEARN
-------------------
- BT.downloadFrame(filename, { size }) takes a screenshot of the next rendered frame and
saves it as a PNG file on your computer.
- BT.captureFrame({ size }) takes the same screenshot but hands you the PNG as a Blob (a
chunk of file data in memory) instead of saving it.
- The size option: 'output' (the default) gives the full drawing buffer, 'display' gives
exactly one PNG pixel per game pixel.
- displaySize vs drawingBufferSize in configure(): this demo draws on a 320x240 grid but
asks for a 640x480 drawing buffer, so the two capture sizes produce different images.
- Promises: both calls return a promise, and the demo reacts with .then() when the capture
finishes and .catch() when it fails.
- Palette animation: update() writes new colors into six palette slots every tick, and
render() keeps drawing with the same slot numbers.
- isOverlayToggleHintVisible: hiding the engine's overlay hint so it does not end up in
the saved picture.
WHICH SIZE IS WHICH?
--------------------
Most games never set displaySize or drawingBufferSize and inherit the engine default: a
320x240 game grid drawn into a 640x480 buffer, so every game pixel is a crisp 2x2 block
on screen. This demo spells both sizes out in configure() so the choice is visible.
- 'output' captures BT.outputSize, the 640x480 buffer, including any display-tier
post-process effects (CRT, vignette, and the like). This is what the player sees.
- 'display' captures BT.displaySize, the 320x240 game grid, with no upscale and no
display-tier effects. Best for pixel-art export and image comparisons.
The "Size" button (key Z) switches between the two, and the status row after a capture
tells you which one was used.
The kit panel is drawn onto the frame like everything else, so it appears in the saved
PNG too. That is on purpose: it doubles as a caption saying which demo made the picture,
and it is what lets touch users save at all.
DEV-MODE SHORTCUTS
------------------
The engine itself adds two capture shortcuts in development builds (running from
`pnpm run dev`, not a production build), for every game, not just this demo: F9 copies
the current frame to the clipboard, and Shift+F9 downloads it as a timestamped PNG file.
Both capture at BT.displaySize, so they match this demo's Save PNG button only while the
Size button says 'display'. HardwareSettings.isFrameCaptureShortcutEnabled turns them on
or off explicitly; the default follows BT.isDevMode. In dev mode the engine also exposes
the whole BT namespace as window.BT (BootstrapOptions.exposeGlobal), so you can type
window.BT.downloadFrame('my-file.png') in the browser console at any time.
TRY THIS
--------
- Press S, or click or tap "Save PNG (S)", to download the current frame. The status row
turns green with the file name on success, or orange with the error.
- Press Z to switch the size to 'display', save again, and compare the two files: one is
640x480 with 2x2 blocks, the other is 320x240 with single pixels.
- Press B, or click "Capture Blob (B)", to capture without saving. The status row shows
the picture's width, height, and byte count read straight from the Blob.
- Press the backquote key (`) to open the engine overlay, then save: the overlay is drawn
on top of the frame, so it ends up in the PNG too. Press ` again to hide it first when
you want a clean capture.
*/
import { function bootstrap(DemoClass: DemoConstructor, options?: BootstrapOptions): Promise<boolean>One-liner bootstrap function for BLIT386 demos.
Handles canvas retrieval and engine initialization. Backend selection
(WebGPU or software fallback) is managed internally by BTAPI.
This function provides a streamlined way to start a demo with sensible defaults
while allowing customization through options.bootstrap, const BT: {
FLIP_H: number;
FLIP_V: number;
ROT_90_CW: number;
ROT_180_CW: number;
ROT_270_CW: number;
BTN_UP: number;
BTN_DOWN: number;
BTN_LEFT: number;
BTN_RIGHT: number;
BTN_A: number;
BTN_B: number;
BTN_X: number;
BTN_Y: number;
BTN_L: number;
BTN_R: number;
BTN_START: number;
BTN_SELECT: number;
BTN_POINTER_A: number;
BTN_POINTER_B: number;
BTN_POINTER_C: number;
BTN_POINTER_D: number;
PLAYER_ONE: number;
PLAYER_TWO: number;
PLAYER_THREE: number;
PLAYER_FOUR: number;
AXIS_LEFT_X: number;
AXIS_LEFT_Y: number;
AXIS_RIGHT_X: number;
AXIS_RIGHT_Y: number;
AXIS_TRIGGER_L: number;
... 124 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT, class Color32Mutable 32-bit RGBA color value with 8-bit channels.Color32, class Rect2iInteger rectangle for pixel-perfect bounds and regions.
Used throughout the engine for sprite regions, display-space bounds, and
zero-allocation geometry helpers. Both convenience getters and allocation-free
`*To()` helpers are provided so callers can choose between readability and
hot-path efficiency.Rect2i, class Vector2iInteger 2D vector for pixel-perfect positioning.
Used for points, sizes, directions, and camera offsets throughout the engine.
The API includes both allocation-free `*To()` / `*InPlace()` variants and
convenience methods that return new vectors.Vector2i } from 'blit386';
import { import applyThemeapplyTheme, import uiui, import UI_ANCHORSUI_ANCHORS } from './shared/ui.js';
/** @typedef {import('blit386').Palette} Palette */
// Every color used for drawing is stored in a numbered "palette" slot. Think of each slot
// like a labeled paint jar on an artist's shelf. Slot 0 is always transparent, so our own
// colors start at 1. These are the scene colors - the test pattern itself. The panel,
// buttons, and text draw with the shared UI theme instead (installed by applyTheme() in
// init()).
const const C_WHITE: 1C_WHITE = 1; // grid dots, border, and crosshairs
const const C_BG: 2C_BG = 2; // very dark blue-gray background
// Dynamic slots: these six colors change every tick to create the animated rainbow
// stripes. We reserve slots 10 through 15, one for each horizontal stripe. update()
// calculates the new color and stores it here; render() just uses the slot number. This
// is called "palette animation" - the retro trick that made old consoles look alive.
const const C_STRIPE_0: 10C_STRIPE_0 = 10; // top stripe; stripes 1-5 follow at C_STRIPE_0 + 1 through + 5
// The two capture sizes the engine understands (the FrameCaptureSize type). Named
// constants, so a typo cannot silently pick the wrong one.
const const SIZE_OUTPUT: "output"SIZE_OUTPUT = 'output'; // the whole drawing buffer, including display effects
const const SIZE_DISPLAY: "display"SIZE_DISPLAY = 'display'; // exact game pixels, one PNG pixel per game pixel
// Draws a colorful test pattern and saves the frame as a PNG when the kit's "Save PNG"
// button is clicked, tapped, or triggered with the S key.
class class DemoDemo {
// The palette holds the list of colors the engine uses for drawing.
/** @type {Palette | null} */
Demo.palette: Palette | nullpalette = null;
// Counts how many update() ticks have run since the demo started (goes up by 1 each
// tick). We use it to animate the stripes and to show a frame number in the panel.
Demo.tick: numbertick = 0;
// True while we are waiting for a capture to finish. It stops you from starting two
// captures at once and makes render() show "Capturing..." on screen.
Demo.capturing: booleancapturing = false;
// The text we show after a capture succeeds or fails (the file name, the Blob size,
// or an error).
Demo.lastCaptureMessage: stringlastCaptureMessage = '';
// Which UI color role to draw that message with: 'accent' (green) for success, 'warm'
// (orange) for an error.
Demo.lastCaptureColor: stringlastCaptureColor = 'accent';
// The size option we pass to BT.downloadFrame() and BT.captureFrame(). 'output' means
// the full drawing buffer (640x480 here); 'display' means one PNG pixel per game
// pixel (320x240 here). The Size button switches between them.
Demo.captureSize: stringcaptureSize = const SIZE_OUTPUT: "output"SIZE_OUTPUT;
// Counts down how many more ticks to show lastCaptureMessage before hiding it. 180
// ticks is 3 seconds at 60 ticks per second.
Demo.messageTimer: numbermessageTimer = 0;
// Hides the overlay toggle hint so saved screenshots stay clean, and sets a 320x240
// display on a 640x480 drawing buffer so the two capture sizes differ.
Demo.configure(): {
isOverlayToggleHintVisible: boolean;
displaySize: Vector2i;
drawingBufferSize: Vector2i;
}
configure() {
return {
// The engine usually draws a small "~" hint in the bottom-left corner to tell
// people they can press the backquote key (`) to open the stats overlay. This
// demo's whole point is saving a picture, and the overlay is drawn on top of
// everything, so that hint would end up baked into the saved PNG. We hide the
// hint to keep captures tidy. (The kit panel DOES appear in the capture - a
// deliberate trade-off so touch users can save at all; see render().) The
// overlay still works on demand: press ` to show it and ` again to hide it
// before you capture. The bottom-left 17x13 corner also stays tappable to
// toggle it, which is why our panel sits in the top-left instead.
isOverlayToggleHintVisible: booleanisOverlayToggleHintVisible: false,
// Most demos leave displaySize unset and let the engine fill in its default
// hardware profile, which already includes a 640x480 drawing buffer - an
// internal 2x upscale so the picture looks crisp on screen. Here we declare
// both sizes ourselves so the demo is easy to read: displaySize is the grid
// the game draws on (320x240 pixels).
displaySize: Vector2idisplaySize: new new Vector2i(x?: number, y?: number): Vector2iCreates an integer 2D vector, truncating inputs toward zero.Vector2i(320, 240),
// Now we ask for a bigger drawing buffer on purpose. This is the "scaled
// display" case: the game still draws on a 320x240 grid, but the engine keeps
// a 640x480 picture for the screen. BT.outputSize is that 640x480 size, so a
// capture with size 'output' is 640x480 and a capture with size 'display' is
// 320x240. Declaring it explicitly keeps the choice visible instead of
// depending on engine defaults.
drawingBufferSize: Vector2idrawingBufferSize: new new Vector2i(x?: number, y?: number): Vector2iCreates an integer 2D vector, truncating inputs toward zero.Vector2i(640, 480),
};
}
// Sets up the color palette and installs the shared UI theme colors. The buttons
// themselves are declared every frame in render().
async Demo.init(): unknowninit() {
// Step 1: build the color palette. 256 slots is the classic retro amount.
this.Demo.palette: Palette | nullpalette = const BT: {
FLIP_H: number;
FLIP_V: number;
ROT_90_CW: number;
ROT_180_CW: number;
ROT_270_CW: number;
BTN_UP: number;
BTN_DOWN: number;
BTN_LEFT: number;
BTN_RIGHT: number;
BTN_A: number;
BTN_B: number;
BTN_X: number;
BTN_Y: number;
BTN_L: number;
BTN_R: number;
BTN_START: number;
BTN_SELECT: number;
BTN_POINTER_A: number;
BTN_POINTER_B: number;
BTN_POINTER_C: number;
BTN_POINTER_D: number;
PLAYER_ONE: number;
PLAYER_TWO: number;
PLAYER_THREE: number;
PLAYER_FOUR: number;
AXIS_LEFT_X: number;
AXIS_LEFT_Y: number;
AXIS_RIGHT_X: number;
AXIS_RIGHT_Y: number;
AXIS_TRIGGER_L: number;
... 124 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.paletteCreate: (size?: number) => PaletteCreates a standalone palette instance.paletteCreate(256);
// Store the static scene colors we always need.
this.Demo.palette: Palettepalette.Palette.set(index: number, color: Color32): voidWrites a color into a palette slot.set(const C_WHITE: 1C_WHITE, new new Color32(r?: number, g?: number, b?: number, a?: number): Color32Creates a clamped 8-bit RGBA color.Color32(255, 255, 255)); // pure white
this.Demo.palette: Palettepalette.Palette.set(index: number, color: Color32): voidWrites a color into a palette slot.set(const C_BG: 2C_BG, new new Color32(r?: number, g?: number, b?: number, a?: number): Color32Creates a clamped 8-bit RGBA color.Color32(20, 20, 30)); // very dark blue-gray background
// Pre-fill the six animated stripe slots with a starting color (dark gray).
// update() replaces them on the very first tick; filling them now prevents any
// "empty slot" glitches on the first frame.
for (let let i: numberi = 0; let i: numberi < 6; let i: numberi++) {
this.Demo.palette: Palettepalette.Palette.set(index: number, color: Color32): voidWrites a color into a palette slot.set(const C_STRIPE_0: 10C_STRIPE_0 + let i: numberi, new new Color32(r?: number, g?: number, b?: number, a?: number): Color32Creates a clamped 8-bit RGBA color.Color32(40, 40, 40));
}
// Step 2: install the shared UI theme. applyTheme() writes the twelve UI kit
// colors into high palette slots (240-251 by default), far above our scene slots
// 1-15. Every kit widget (the panel, buttons, labels) draws with these colors
// automatically - this demo never needs the slot numbers itself, so we call
// applyTheme() only for that side effect and ignore its return value.
import applyThemeapplyTheme(this.Demo.palette: Palettepalette);
// Tell the engine "use this palette from now on."
const BT: {
FLIP_H: number;
FLIP_V: number;
ROT_90_CW: number;
ROT_180_CW: number;
ROT_270_CW: number;
BTN_UP: number;
BTN_DOWN: number;
BTN_LEFT: number;
BTN_RIGHT: number;
BTN_A: number;
BTN_B: number;
BTN_X: number;
BTN_Y: number;
BTN_L: number;
BTN_R: number;
BTN_START: number;
BTN_SELECT: number;
BTN_POINTER_A: number;
BTN_POINTER_B: number;
BTN_POINTER_C: number;
BTN_POINTER_D: number;
PLAYER_ONE: number;
PLAYER_TWO: number;
PLAYER_THREE: number;
PLAYER_FOUR: number;
AXIS_LEFT_X: number;
AXIS_LEFT_Y: number;
AXIS_RIGHT_X: number;
AXIS_RIGHT_Y: number;
AXIS_TRIGGER_L: number;
... 124 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.paletteSet: (palette: Palette) => voidStores the active engine palette.
Use this to swap the **entire palette** (e.g. switch between a day and night
theme). After this call the renderer uploads the new palette uniform on the
next frame.
**Palette-value swap (change what a slot looks like):** mutate the live
{@link
BT.palette
}
in place with `palette.set(slot, newColor)`. The renderer
uploads dirty slots on the next frame; no `paletteSet()` or
{@link
BT.spritesRefresh
}
needed.
**Palette-layout swap (same colors, different slot positions):** build a new
palette with the same colors at new indices, call `paletteSet()`, then call
{@link
BT.spritesRefresh
}
so every sprite sheet re-maps its original RGBA
pixels against the new slot layout.paletteSet(this.Demo.palette: Palettepalette);
return true;
}
// Advances the demo clock, expires the status message, and updates the animated
// stripe colors in the palette. Runs at a fixed rate (60 times per second).
Demo.update(): voidupdate() {
// Let the UI kit do its per-tick housekeeping first: it latches keyboard
// shortcuts (like the Save button's S binding) and tracks touch contacts. This
// must be the first line of update() so nothing misses a key press.
import uiui.tick();
// Bump the tick counter so the animation and the on-screen FRAME row keep
// changing.
this.Demo.tick: numbertick++;
// If we are showing a success or error message, count down until it should go.
if (this.Demo.messageTimer: numbermessageTimer > 0) {
this.Demo.messageTimer: numbermessageTimer--;
}
// Palette animation for the six horizontal stripes. Instead of computing colors
// inside render(), we compute them here in update() and store the results in the
// reserved palette slots. render() then just uses the slot numbers.
for (let let i: numberi = 0; let i: numberi < 6; let i: numberi++) {
// phase is an angle-like value from 0 to 359 that moves as tick increases.
// Each stripe adds i * 20 so neighboring stripes do not look identical. The %
// 360 (remainder after dividing by 360) keeps the value from growing forever
// - it wraps back to 0 at 360.
const const phase: numberphase = (this.Demo.tick: numbertick + let i: numberi * 20) % 360;
// Red channel: Math.sin returns a wave between -1 and +1. Multiplying by 127
// and adding 127 shifts that to a range of 0 to 254. Math.PI / 180 converts
// degrees to radians, which is what Math.sin expects.
const const r: anyr = Math.floor(127 + 127 * Math.sin((const phase: numberphase * Math.PI) / 180));
// Green channel: the same sine wave shifted 120 degrees around the color
// wheel. Shifting each channel separately makes R, G, and B cycle out of
// step, which creates the rainbow effect as the phase changes.
const const g: anyg = Math.floor(127 + 127 * Math.sin(((const phase: numberphase + 120) * Math.PI) / 180));
// Blue channel: shifted 240 degrees so all three are evenly spaced.
const const b: anyb = Math.floor(127 + 127 * Math.sin(((const phase: numberphase + 240) * Math.PI) / 180));
// Store the computed color in the palette slot for this stripe. render()
// reads C_STRIPE_0 + i to draw each stripe - no Color32 needed there.
this.Demo.palette: Palette | nullpalette.Palette.set(index: number, color: Color32): voidWrites a color into a palette slot.set(const C_STRIPE_0: 10C_STRIPE_0 + let i: numberi, new new Color32(r?: number, g?: number, b?: number, a?: number): Color32Creates a clamped 8-bit RGBA color.Color32(const r: anyr, const g: anyg, const b: anyb));
}
}
// Draws the animated test pattern and the UI kit panel with the buttons and the
// capture status. Runs once per screen refresh.
Demo.render(): voidrender() {
// Ask the engine how wide and tall our game screen is in pixels.
const const screen: Vector2iscreen = const BT: {
FLIP_H: number;
FLIP_V: number;
ROT_90_CW: number;
ROT_180_CW: number;
ROT_270_CW: number;
BTN_UP: number;
BTN_DOWN: number;
BTN_LEFT: number;
BTN_RIGHT: number;
BTN_A: number;
BTN_B: number;
BTN_X: number;
BTN_Y: number;
BTN_L: number;
BTN_R: number;
BTN_START: number;
BTN_SELECT: number;
BTN_POINTER_A: number;
BTN_POINTER_B: number;
BTN_POINTER_C: number;
BTN_POINTER_D: number;
PLAYER_ONE: number;
PLAYER_TWO: number;
PLAYER_THREE: number;
PLAYER_FOUR: number;
AXIS_LEFT_X: number;
AXIS_LEFT_Y: number;
AXIS_RIGHT_X: number;
AXIS_RIGHT_Y: number;
AXIS_TRIGGER_L: number;
... 124 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.displaySize: Vector2iActive logical render resolution in pixels.
This is the game/simulation coordinate space configured by the demo, not
the canvas element's CSS size. Each read returns a clone.displaySize;
// Fill the whole screen with the dark background before drawing anything else.
// C_BG is just a number (2); the palette knows it means dark blue-gray.
const BT: {
FLIP_H: number;
FLIP_V: number;
ROT_90_CW: number;
ROT_180_CW: number;
ROT_270_CW: number;
BTN_UP: number;
BTN_DOWN: number;
BTN_LEFT: number;
BTN_RIGHT: number;
BTN_A: number;
BTN_B: number;
BTN_X: number;
BTN_Y: number;
BTN_L: number;
BTN_R: number;
BTN_START: number;
BTN_SELECT: number;
BTN_POINTER_A: number;
BTN_POINTER_B: number;
BTN_POINTER_C: number;
BTN_POINTER_D: number;
PLAYER_ONE: number;
PLAYER_TWO: number;
PLAYER_THREE: number;
PLAYER_FOUR: number;
AXIS_LEFT_X: number;
AXIS_LEFT_Y: number;
AXIS_RIGHT_X: number;
AXIS_RIGHT_Y: number;
AXIS_TRIGGER_L: number;
... 124 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.clear: (paletteIndex: number) => voidSets the frame clear color using a palette index.
The renderer uses this color when clearing the full display at the start
of the next frame.clear(const C_BG: 2C_BG);
// Draw six horizontal stripes using the animated palette slots we updated in
// update(). Each stripe's color was already computed and stored - we just use the
// slot number.
const const stripeHeight: 40stripeHeight = 40;
for (let let i: numberi = 0; let i: numberi < 6; let i: numberi++) {
// y is the top edge of this stripe in pixels.
const const y: numbery = let i: numberi * const stripeHeight: 40stripeHeight;
// C_STRIPE_0 + i picks the palette slot for this stripe (10, 11, 12, ...).
const BT: {
FLIP_H: number;
FLIP_V: number;
ROT_90_CW: number;
ROT_180_CW: number;
ROT_270_CW: number;
BTN_UP: number;
BTN_DOWN: number;
BTN_LEFT: number;
BTN_RIGHT: number;
BTN_A: number;
BTN_B: number;
BTN_X: number;
BTN_Y: number;
BTN_L: number;
BTN_R: number;
BTN_START: number;
BTN_SELECT: number;
BTN_POINTER_A: number;
BTN_POINTER_B: number;
BTN_POINTER_C: number;
BTN_POINTER_D: number;
PLAYER_ONE: number;
PLAYER_TWO: number;
PLAYER_THREE: number;
PLAYER_FOUR: number;
AXIS_LEFT_X: number;
AXIS_LEFT_Y: number;
AXIS_RIGHT_X: number;
AXIS_RIGHT_Y: number;
AXIS_TRIGGER_L: number;
... 124 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.drawRectFill: (rect: Rect2i, paletteIndex: number) => voidDraws a filled rectangle.drawRectFill(new new Rect2i(x?: number, y?: number, width?: number, height?: number): Rect2iCreates an integer rectangle, truncating all inputs toward zero.Rect2i(0, const y: numbery, const screen: Vector2iscreen.Vector2i.x: numberHorizontal component (defaults to 0).x, const stripeHeight: 40stripeHeight), const C_STRIPE_0: 10C_STRIPE_0 + let i: numberi);
}
// A grid of single white pixels every 20 pixels, so you can check alignment when
// you open the PNG.
for (let let x: numberx = 0; let x: numberx < const screen: Vector2iscreen.Vector2i.x: numberHorizontal component (defaults to 0).x; let x: numberx += 20) {
for (let let y: numbery = 0; let y: numbery < const screen: Vector2iscreen.Vector2i.y: numberVertical component (defaults to 0).y; let y: numbery += 20) {
const BT: {
FLIP_H: number;
FLIP_V: number;
ROT_90_CW: number;
ROT_180_CW: number;
ROT_270_CW: number;
BTN_UP: number;
BTN_DOWN: number;
BTN_LEFT: number;
BTN_RIGHT: number;
BTN_A: number;
BTN_B: number;
BTN_X: number;
BTN_Y: number;
BTN_L: number;
BTN_R: number;
BTN_START: number;
BTN_SELECT: number;
BTN_POINTER_A: number;
BTN_POINTER_B: number;
BTN_POINTER_C: number;
BTN_POINTER_D: number;
PLAYER_ONE: number;
PLAYER_TWO: number;
PLAYER_THREE: number;
PLAYER_FOUR: number;
AXIS_LEFT_X: number;
AXIS_LEFT_Y: number;
AXIS_RIGHT_X: number;
AXIS_RIGHT_Y: number;
AXIS_TRIGGER_L: number;
... 124 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.drawPixel: (posOrX: Vector2i | number, yOrColor: number, maybeColor?: number) => voidDraws a single pixel.
Accepts either:
- `(posOrX: Vector2i, yOrColor: number)` where `yOrColor` is the palette index.
- `(posOrX: number, yOrColor: number, maybeColor: number)` for `(x, y, paletteIndex)`.drawPixel(new new Vector2i(x?: number, y?: number): Vector2iCreates an integer 2D vector, truncating inputs toward zero.Vector2i(let x: numberx, let y: numbery), const C_WHITE: 1C_WHITE);
}
}
// Outline the entire display with a white rectangle so the edges are obvious in
// the screenshot.
const BT: {
FLIP_H: number;
FLIP_V: number;
ROT_90_CW: number;
ROT_180_CW: number;
ROT_270_CW: number;
BTN_UP: number;
BTN_DOWN: number;
BTN_LEFT: number;
BTN_RIGHT: number;
BTN_A: number;
BTN_B: number;
BTN_X: number;
BTN_Y: number;
BTN_L: number;
BTN_R: number;
BTN_START: number;
BTN_SELECT: number;
BTN_POINTER_A: number;
BTN_POINTER_B: number;
BTN_POINTER_C: number;
BTN_POINTER_D: number;
PLAYER_ONE: number;
PLAYER_TWO: number;
PLAYER_THREE: number;
PLAYER_FOUR: number;
AXIS_LEFT_X: number;
AXIS_LEFT_Y: number;
AXIS_RIGHT_X: number;
AXIS_RIGHT_Y: number;
AXIS_TRIGGER_L: number;
... 124 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.drawRect: (rect: Rect2i, paletteIndex: number) => voidDraws an unfilled rectangle outline.drawRect(new new Rect2i(x?: number, y?: number, width?: number, height?: number): Rect2iCreates an integer rectangle, truncating all inputs toward zero.Rect2i(0, 0, const screen: Vector2iscreen.Vector2i.x: numberHorizontal component (defaults to 0).x, const screen: Vector2iscreen.Vector2i.y: numberVertical component (defaults to 0).y), const C_WHITE: 1C_WHITE);
// Crosshairs at the exact center: cx is half the width, cy is half the height,
// floored to whole pixels.
const const cx: anycx = Math.floor(const screen: Vector2iscreen.Vector2i.x: numberHorizontal component (defaults to 0).x / 2);
const const cy: anycy = Math.floor(const screen: Vector2iscreen.Vector2i.y: numberVertical component (defaults to 0).y / 2);
// Horizontal line through the center (20 pixels left and right of center).
const BT: {
FLIP_H: number;
FLIP_V: number;
ROT_90_CW: number;
ROT_180_CW: number;
ROT_270_CW: number;
BTN_UP: number;
BTN_DOWN: number;
BTN_LEFT: number;
BTN_RIGHT: number;
BTN_A: number;
BTN_B: number;
BTN_X: number;
BTN_Y: number;
BTN_L: number;
BTN_R: number;
BTN_START: number;
BTN_SELECT: number;
BTN_POINTER_A: number;
BTN_POINTER_B: number;
BTN_POINTER_C: number;
BTN_POINTER_D: number;
PLAYER_ONE: number;
PLAYER_TWO: number;
PLAYER_THREE: number;
PLAYER_FOUR: number;
AXIS_LEFT_X: number;
AXIS_LEFT_Y: number;
AXIS_RIGHT_X: number;
AXIS_RIGHT_Y: number;
AXIS_TRIGGER_L: number;
... 124 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.drawLine: (p0: Vector2i, p1: Vector2i, paletteIndex: number) => voidDraws a pixel-perfect line between two points.
Uses rasterized line drawing without antialiasing.drawLine(new new Vector2i(x?: number, y?: number): Vector2iCreates an integer 2D vector, truncating inputs toward zero.Vector2i(const cx: anycx - 20, const cy: anycy), new new Vector2i(x?: number, y?: number): Vector2iCreates an integer 2D vector, truncating inputs toward zero.Vector2i(const cx: anycx + 20, const cy: anycy), const C_WHITE: 1C_WHITE);
// Vertical line through the center (20 pixels up and down from center).
const BT: {
FLIP_H: number;
FLIP_V: number;
ROT_90_CW: number;
ROT_180_CW: number;
ROT_270_CW: number;
BTN_UP: number;
BTN_DOWN: number;
BTN_LEFT: number;
BTN_RIGHT: number;
BTN_A: number;
BTN_B: number;
BTN_X: number;
BTN_Y: number;
BTN_L: number;
BTN_R: number;
BTN_START: number;
BTN_SELECT: number;
BTN_POINTER_A: number;
BTN_POINTER_B: number;
BTN_POINTER_C: number;
BTN_POINTER_D: number;
PLAYER_ONE: number;
PLAYER_TWO: number;
PLAYER_THREE: number;
PLAYER_FOUR: number;
AXIS_LEFT_X: number;
AXIS_LEFT_Y: number;
AXIS_RIGHT_X: number;
AXIS_RIGHT_Y: number;
AXIS_TRIGGER_L: number;
... 124 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.drawLine: (p0: Vector2i, p1: Vector2i, paletteIndex: number) => voidDraws a pixel-perfect line between two points.
Uses rasterized line drawing without antialiasing.drawLine(new new Vector2i(x?: number, y?: number): Vector2iCreates an integer 2D vector, truncating inputs toward zero.Vector2i(const cx: anycx, const cy: anycy - 20), new new Vector2i(x?: number, y?: number): Vector2iCreates an integer 2D vector, truncating inputs toward zero.Vector2i(const cx: anycx, const cy: anycy + 20), const C_WHITE: 1C_WHITE);
// UI kit panel in the top-left corner. We avoid the bottom-left because the
// engine keeps that 17x13 corner tappable for toggling the stats overlay, and we
// do not want the two to fight over taps; the top-left has no such conflict. This
// panel is drawn onto the frame, so it WILL be part of the saved PNG. That is
// acceptable here - it even doubles as a caption telling you which demo made the
// screenshot.
import uiui.begin(import UI_ANCHORSUI_ANCHORS.TOP_LEFT);
import uiui.panel('Image Output');
// The Save button. ui.button() returns true only on the single frame it was
// clicked, tapped, or its bound key (S) was pressed - so holding S does not spam
// downloads. We also ignore it while a capture is already running. Bound to S
// rather than Space: Space is the browser's page-scroll key, and this demo does
// not opt into isCapturingKeyboardScroll, so a Space press would both save AND
// scroll the page - confusing on a page taller than the canvas.
if (import uiui.button('Save PNG (S)', { key: stringkey: 'KeyS' }) && !this.Demo.capturing: booleancapturing) {
this.Demo.saveFrame(): voidsaveFrame();
}
// The Size button flips between the two FrameCaptureSize choices. Its label shows
// the choice that is active right now, so you can see what the next capture will
// use. The label changes, so the button needs a fixed id to stay the same widget.
if (import uiui.button(`Size: ${this.Demo.captureSize: stringcaptureSize} (Z)`, { key: stringkey: 'KeyZ', id: stringid: 'size' })) {
this.Demo.captureSize: stringcaptureSize = this.Demo.captureSize: stringcaptureSize === const SIZE_OUTPUT: "output"SIZE_OUTPUT ? const SIZE_DISPLAY: "display"SIZE_DISPLAY : const SIZE_OUTPUT: "output"SIZE_OUTPUT;
}
// The Capture Blob button calls BT.captureFrame() instead of BT.downloadFrame().
// Nothing is saved to disk - the demo gets the PNG data and reads it itself.
if (import uiui.button('Capture Blob (B)', { key: stringkey: 'KeyB' }) && !this.Demo.capturing: booleancapturing) {
this.Demo.captureBlob(): voidcaptureBlob();
}
// One status row below the buttons. We always draw a row (even when idle) so the
// panel does not jump in size when a message appears or disappears.
if (this.Demo.capturing: booleancapturing) {
// A capture is in flight - the browser is busy reading the canvas.
import uiui.label('Capturing...', { color: stringcolor: 'info' });
} else if (this.Demo.messageTimer: numbermessageTimer > 0) {
// A capture just finished - show the result in green (success) or orange
// (error).
import uiui.label(this.Demo.lastCaptureMessage: stringlastCaptureMessage, { color: stringcolor: this.Demo.lastCaptureColor: stringlastCaptureColor });
} else {
// Nothing happening - a quiet hint in dim gray. In dev mode (running from
// `pnpm run dev`, not a production build) we also mention the F9/Shift+F9
// shortcuts. They are engine defaults (every game gets them, see
// HardwareSettings.isFrameCaptureShortcutEnabled), not something this demo
// wires up itself; it has no button of its own to advertise them, so this
// hint is the most relevant place.
const const hint1: "Saves the current frame"hint1 = 'Saves the current frame';
const const hint2: "" | "(dev: F9 copies, Shift+F9 saves)"hint2 = const BT: {
FLIP_H: number;
FLIP_V: number;
ROT_90_CW: number;
ROT_180_CW: number;
ROT_270_CW: number;
BTN_UP: number;
BTN_DOWN: number;
BTN_LEFT: number;
BTN_RIGHT: number;
BTN_A: number;
BTN_B: number;
BTN_X: number;
BTN_Y: number;
BTN_L: number;
BTN_R: number;
BTN_START: number;
BTN_SELECT: number;
BTN_POINTER_A: number;
BTN_POINTER_B: number;
BTN_POINTER_C: number;
BTN_POINTER_D: number;
PLAYER_ONE: number;
PLAYER_TWO: number;
PLAYER_THREE: number;
PLAYER_FOUR: number;
AXIS_LEFT_X: number;
AXIS_LEFT_Y: number;
AXIS_RIGHT_X: number;
AXIS_RIGHT_Y: number;
AXIS_TRIGGER_L: number;
... 124 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.isDevMode: booleanWhether this is a development build, not a release build.
Resolves from the `blit386/vite` plugin's injected runtime marker, falling back to a live
Vite HMR context, otherwise release. (The underlying resolver also accepts an explicit
override that always wins over both; nothing in the public `BT` surface supplies one today.)
This is UX/DX gating, not DRM - any consumer can flip the underlying global by hand.isDevMode ? '(dev: F9 copies, Shift+F9 saves)' : '';
import uiui.label(const hint1: "Saves the current frame"hint1, { color: stringcolor: 'dim' });
import uiui.label(const hint2: "" | "(dev: F9 copies, Shift+F9 saves)"hint2, { color: stringcolor: 'dim' });
}
// Frame counter so you can tell consecutive screenshots apart.
import uiui.kv('FRAME', this.Demo.tick: numbertick);
import uiui.end();
}
// Starts a PNG download of the current frame and remembers the outcome so render()
// can show a status message for a few seconds.
Demo.saveFrame(): voidsaveFrame() {
// Mark the capture as "in flight" so the buttons ignore further presses and
// render() shows the "Capturing..." status row.
this.Demo.capturing: booleancapturing = true;
// Remember which size we asked for. You may press Z while we wait.
const const size: stringsize = this.Demo.captureSize: stringcaptureSize;
// BT.downloadFrame() reads the canvas and asks the browser to save a file. Most
// browsers open a "Save as" dialog or drop the file straight into your Downloads
// folder (depends on your browser settings). The demo cannot pick the folder for
// you - that is normal browser security.
const BT: {
FLIP_H: number;
FLIP_V: number;
ROT_90_CW: number;
ROT_180_CW: number;
ROT_270_CW: number;
BTN_UP: number;
BTN_DOWN: number;
BTN_LEFT: number;
BTN_RIGHT: number;
BTN_A: number;
BTN_B: number;
BTN_X: number;
BTN_Y: number;
BTN_L: number;
BTN_R: number;
BTN_START: number;
BTN_SELECT: number;
BTN_POINTER_A: number;
BTN_POINTER_B: number;
BTN_POINTER_C: number;
BTN_POINTER_D: number;
PLAYER_ONE: number;
PLAYER_TWO: number;
PLAYER_THREE: number;
PLAYER_FOUR: number;
AXIS_LEFT_X: number;
AXIS_LEFT_Y: number;
AXIS_RIGHT_X: number;
AXIS_RIGHT_Y: number;
AXIS_TRIGGER_L: number;
... 124 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.downloadFrame: (filename?: string, options?: FrameCaptureOptions) => Promise<void>Captures the next rendered frame and downloads it from the browser.
Convenience wrapper around
{@link
BT.captureFrame
}
that creates a temporary
object URL and clicks a synthetic anchor element. Takes the same `options` as
{@link
BT.captureFrame
}
.downloadFrame('blit386-capture.png', { size: stringsize })
.then(() => {
// Success: show a friendly message in green.
this.Demo.showMessage(text: any, color: any): voidshowMessage(`Saved: blit386-capture.png (${const size: stringsize})`, 'accent');
return null;
})
.catch((err: anyerr) => {
// Failure: show the error in orange and log the details for developers.
this.Demo.showMessage(text: any, color: any): voidshowMessage(`Error: ${err: anyerr.message}`, 'warm');
console.error('[Demo] Capture failed:', err: anyerr);
});
}
// Captures the current frame as a Blob with BT.captureFrame() and reads the Blob's
// byte count and picture dimensions for the status row.
Demo.captureBlob(): voidcaptureBlob() {
// Same "in flight" flag as saveFrame(), so only one capture runs at a time.
this.Demo.capturing: booleancapturing = true;
// Remember which size we asked for. You may press Z while we wait.
const const size: stringsize = this.Demo.captureSize: stringcaptureSize;
// BT.captureFrame() resolves with a Blob: the PNG file held in memory. The
// options object picks the size, exactly like BT.downloadFrame().
const BT: {
FLIP_H: number;
FLIP_V: number;
ROT_90_CW: number;
ROT_180_CW: number;
ROT_270_CW: number;
BTN_UP: number;
BTN_DOWN: number;
BTN_LEFT: number;
BTN_RIGHT: number;
BTN_A: number;
BTN_B: number;
BTN_X: number;
BTN_Y: number;
BTN_L: number;
BTN_R: number;
BTN_START: number;
BTN_SELECT: number;
BTN_POINTER_A: number;
BTN_POINTER_B: number;
BTN_POINTER_C: number;
BTN_POINTER_D: number;
PLAYER_ONE: number;
PLAYER_TWO: number;
PLAYER_THREE: number;
PLAYER_FOUR: number;
AXIS_LEFT_X: number;
AXIS_LEFT_Y: number;
AXIS_RIGHT_X: number;
AXIS_RIGHT_Y: number;
AXIS_TRIGGER_L: number;
... 124 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.captureFrame: (options?: FrameCaptureOptions) => Promise<Blob>Captures the next rendered frame as a PNG blob.
The returned promise resolves after the next render pass has completed. By default
the PNG matches
{@link
BT.outputSize
}
(`drawingBufferSize ?? displaySize`), including
display-tier post-process effects. Pass `{ size: 'display' }` for an exact 1:1 frame
at logical
{@link
BT.displaySize
}
, resolved straight from the palette-indexed scene
buffer - no upscale and no display-tier effects (scanlines, vignette, bloom) - which
is what tests and agents want for pixel comparisons. The display-size capture has
its own pending slot, so it never contends with the F9 / Shift+F9 dev shortcuts.captureFrame({ size: stringsize })
.then(async (blob: anyblob) => {
// createImageBitmap() decodes the PNG so we can read its real width and
// height. This proves the Blob holds the size we asked for.
const const image: anyimage = await createImageBitmap(blob: anyblob);
const const message: stringmessage = `Blob ${const size: stringsize}: ${const image: anyimage.width}x${const image: anyimage.height}, ${blob: anyblob.size} bytes`;
// Free the decoded picture; we only needed its dimensions.
const image: anyimage.close();
this.Demo.showMessage(text: any, color: any): voidshowMessage(const message: stringmessage, 'accent');
return null;
})
.catch((err: anyerr) => {
// Failure: show the error in orange and log the details for developers.
this.Demo.showMessage(text: any, color: any): voidshowMessage(`Error: ${err: anyerr.message}`, 'warm');
console.error('[Demo] Blob capture failed:', err: anyerr);
});
}
// Ends a capture: shows a status message for three seconds and unlocks the buttons.
// `color` is a UI color role: 'accent' (green) for success, 'warm' (orange) for an
// error.
Demo.showMessage(text: any, color: any): voidshowMessage(text: anytext, color: anycolor) {
this.Demo.lastCaptureMessage: stringlastCaptureMessage = text: anytext;
this.Demo.lastCaptureColor: stringlastCaptureColor = color: anycolor;
this.Demo.messageTimer: numbermessageTimer = 180; // 3 seconds at 60 ticks per second
this.Demo.capturing: booleancapturing = false;
}
}
// Hand the Demo class to BLIT386 to start the demo loop.
function bootstrap(DemoClass: DemoConstructor, options?: BootstrapOptions): Promise<boolean>One-liner bootstrap function for BLIT386 demos.
Handles canvas retrieval and engine initialization. Backend selection
(WebGPU or software fallback) is managed internally by BTAPI.
This function provides a streamlined way to start a demo with sensible defaults
while allowing customization through options.bootstrap(class DemoDemo);