// Flurry: a retro screensaver built on particle physics and palette animation.
// @description A retro screensaver port of the classic macOS Flurry: particle physics driving palette animation.
//
// Ported from the classic macOS Flurry screensaver by Calum Robinson (2002).
// Original source: https://github.com/calumr/flurry
//
// WHAT IS FLURRY?
// Flurry was a free screensaver for macOS that showed a cloud of glowing particles
// swirling around invisible "sparks" - points in space that pull particles toward them
// like tiny gravity wells. Twelve sparks trace beautiful figure-eight-like paths
// (called Lissajous orbits), and hundreds of particles spiral around them.
//
// HOW DOES THIS VERSION DIFFER FROM THE ORIGINAL?
// The original Flurry used "additive blending" - overlapping particles added their light
// together to create soft glowing halos. BLIT386 does not support that technique.
// Instead, we use palette animation: every frame, we rewrite the palette so that young
// particles appear bright and large, while old particles appear dim and small.
// The mesmerizing orbital motion and rainbow color cycling are fully preserved.
//
// KEY PHYSICS CONCEPTS:
// Inverse-square gravity - each particle is pulled toward every spark.
// The closer the particle, the stronger the pull. Same law as real planets.
// Drag - a tiny friction force applied each tick, slowing particles gradually.
// Lissajous orbit - a path traced by two sine waves with different frequencies.
// The spark's x position follows one sine wave; its y position follows another.
// When the frequencies are slightly different, the path never exactly repeats.
//
// Prerequisites:
// Basics https://demos.blit386.dev/basics
// Palette Animation https://demos.blit386.dev/palette-animation
// (guide: https://blit386.dev/docs/guides/palette#runtime-palette-effects)
//
// Live version: https://demos.blit386.dev/flurry
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;
... 106 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';
/** @typedef {import('blit386').IBTDemo} IBTDemo */
/** @typedef {import('blit386').HardwareSettings} HardwareSettings */
/** @typedef {import('blit386').Palette} Palette */
// Target frame rate for fixed update() steps (matches engine defaultConfig).
const const TARGET_FPS: 60TARGET_FPS = 60;
// World space
// Particles and sparks live in a virtual coordinate system measured in abstract "world units".
// FIELD_RANGE is the half-extent: the center is (0, 0), and the edges are ±FIELD_RANGE.
// Sparks are constrained to orbit within 80% of this range, so ±8000 units.
// When drawing, world units are converted to screen pixels using the formulas at the bottom.
const const FIELD_RANGE: 10000FIELD_RANGE = 10000;
// Simulation
// How many particles are kept alive at once.
// More particles = denser, richer swirls, but more work each frame.
// 800 particles at 60 FPS means the physics loop runs 800 × 60 = 48,000 times per second.
const const PARTICLE_COUNT: 800PARTICLE_COUNT = 800;
// How many attractor sparks (invisible gravity wells) orbit the screen at once.
// 12 is the same count as the original Flurry screensaver.
const const SPARK_COUNT: 12SPARK_COUNT = 12;
// Physics constants (from the original Flurry source)
// How strong the gravity pull is between a particle and a spark.
// A larger number means particles get sucked in faster.
const const GRAVITY_CONST: 1500000GRAVITY_CONST = 1500000;
// How much velocity the particle keeps each tick (a fraction between 0 and 1).
// The ** operator is JavaScript's "exponent" symbol: base ** exponent.
// 0.9965^(85/60) is the original Flurry formula; the result is about 0.9950.
// That means each tick a particle keeps 99.5% of its speed - very gentle drag.
const const DRAG_FACTOR: numberDRAG_FACTOR = 0.9965 ** (85 / 60);
// How fast newly spawned particles shoot outward from their spark, in world units per tick.
// Combined with the spark's own velocity (added at spawn), this gives each particle
// a unique direction so they fan out rather than all traveling the same way.
const const STREAM_SPEED: 15STREAM_SPEED = 15;
// How far from the exact spark position a new particle may appear, in world units.
// A small scatter means particles start in a tight cluster very close to the spark.
// Without scatter, every particle would start at the same point and overlap completely.
const const SPAWN_SCATTER: 30SPAWN_SCATTER = 30;
// The fastest a particle is ever allowed to move, in world units per tick.
// Without this cap, a particle that passes very close to a spark could accelerate
// to enormous speeds and shoot instantly off screen.
const const SPEED_CAP: 600SPEED_CAP = 600;
// Palette animation
// How many degrees the global hue rotates per tick.
// At 60 FPS: 0.4 degrees/tick × 60 ticks/sec = 24 degrees/sec.
// One full rotation (360 degrees) takes 360 / 24 = 15 seconds.
// During those 15 seconds every particle cycles through red, orange, yellow, green,
// cyan, blue, violet, and back to red - a complete rainbow.
const const HUE_ADVANCE: 0.4HUE_ADVANCE = 0.4;
// Lightness values (brightness) for the 5 particle age tiers.
// Tier 0 is used for newborn particles (age near 0); tier 4 is used for dying particles (age near 1).
// HSL lightness scale: 0 = pure black, 50 = vivid full color, 100 = pure white.
// Going from 85 down to 20 makes particles fade from near-white to nearly invisible as they age.
const const TIER_LIGHTNESS: {}TIER_LIGHTNESS = [85, 68, 52, 36, 20];
// Screen layout
// The logical resolution of the canvas in pixels. This is the number of "dots" in the display,
// not the size of the window (the window is 2x larger via engine defaultConfig).
const const DISPLAY_W: 320DISPLAY_W = 320;
const const DISPLAY_H: 240DISPLAY_H = 240;
// Half-dimensions: the number of pixels from the center to each edge.
// Used for the world-to-screen conversion: screenX = (worldX / FIELD_RANGE) * HALF_W + HALF_W.
// - (worldX / FIELD_RANGE) gives a fraction from -1 to +1.
// - Multiplying by HALF_W stretches that to -HALF_W..+HALF_W pixels.
// - Adding HALF_W shifts the result to 0..DISPLAY_W, placing (0,0) at the screen center.
const const HALF_W: numberHALF_W = const DISPLAY_W: 320DISPLAY_W / 2; // 160
const const HALF_H: numberHALF_H = const DISPLAY_H: 240DISPLAY_H / 2; // 120
// Palette strip
// A thin strip at the very bottom of the screen shows the live palette.
// Two rows are drawn there:
// Top row (3 px tall): 12 spark-bright slots, each ~26 px wide.
// Bottom row (4 px tall): 40 particle slots (8 hues x 5 tiers), each 8 px wide.
// Watching this strip is the clearest way to see the hue rotation happening in real time.
const const PALETTE_STRIP_SPARK_Y: numberPALETTE_STRIP_SPARK_Y = const DISPLAY_H: 240DISPLAY_H - 7; // Spark color row top edge (y = 233).
const const PALETTE_STRIP_SPARK_H: 3PALETTE_STRIP_SPARK_H = 3; // 3 px tall.
const const PALETTE_STRIP_PART_Y: numberPALETTE_STRIP_PART_Y = const DISPLAY_H: 240DISPLAY_H - 4; // Particle color row top edge (y = 236).
const const PALETTE_STRIP_PART_H: 4PALETTE_STRIP_PART_H = 4; // 4 px tall.
// Palette slot numbers - "addresses" in the 256-slot color table.
// Slot 0 is always transparent; the engine reserves it. Never write to slot 0.
//
// Think of each slot as a numbered paint pot.
// update() refills the pots every tick with fresh Color32 objects.
// render() only reads the pot numbers - it never touches Color32 directly.
// This separation is the essence of palette animation.
const const C_WHITE: 1C_WHITE = 1; // Pure white.
const const C_BG: 2C_BG = 2; // Near-black deep-space background color.
const const C_TITLE: 3C_TITLE = 3; // Golden yellow title text.
const const C_FPS: 5C_FPS = 5; // Very dim gray FPS counter.
const const C_SPARK_CORE: 6C_SPARK_CORE = 6; // White-hot single-pixel center of each spark.
// Particle color ramp: 8 hue bands × 5 brightness tiers = 40 slots (10..49).
// To find the slot for a particle: C_PARTICLE_BASE + hueIndex * 5 + tier
// hueIndex (0..7): which color family the particle belongs to.
// tier (0..4): how bright (0 = newborn/bright, 4 = old/dim).
// Example: hueIndex=2, tier=1 → slot 10 + 2*5 + 1 = slot 21.
const const C_PARTICLE_BASE: 10C_PARTICLE_BASE = 10;
// Spark body colors: one bright vivid slot per spark. Slots 100..111 (12 sparks).
// Spark i uses slot C_SPARK_BRIGHT + i.
const const C_SPARK_BRIGHT: 100C_SPARK_BRIGHT = 100;
// Spark halo colors: one dimmer slot per spark. Slots 112..123 (12 sparks).
// Same hue as the bright slot but lower lightness and saturation, to suggest a glow ring.
const const C_SPARK_HALO: 112C_SPARK_HALO = 112;
// Each row defines one spark's orbit parameters and color offset.
// Format: [freqX, freqY, phaseX, phaseY, hueOffset]
//
// freqX, freqY: how fast the spark oscillates on each axis (in radians per second).
// Slightly different values give each spark a unique, non-repeating figure-eight path.
//
// phaseX, phaseY: starting position on the path (in radians, 0..6.28 = full circle).
// Different phases spread the 12 sparks out so they are not all bunched up at startup.
//
// hueOffset: this spark's "personal" color offset (0..330 degrees, in steps of 30).
// With 12 sparks at 30-degree intervals, each one displays a different rainbow hue.
const const SPARK_TABLE: {}SPARK_TABLE = [
[1.0, 1.1, 0.0, 0.0, 0],
[0.85, 0.95, 0.6, 1.2, 30],
[1.2, 0.8, 1.2, 2.4, 60],
[0.95, 1.3, 1.8, 3.6, 90],
[1.3, 0.9, 2.4, 4.8, 120],
[0.75, 1.2, 3.0, 0.6, 150],
[1.1, 0.75, 3.6, 1.8, 180],
[0.9, 1.0, 4.2, 3.0, 210],
[1.25, 1.15, 4.8, 4.2, 240],
[0.8, 0.85, 0.3, 5.4, 270],
[1.05, 1.25, 5.4, 0.9, 300],
[1.15, 0.95, 5.1, 2.1, 330],
];
/**
* Converts one axis of a world position into a screen pixel coordinate.
*
* Two steps happen here:
* 1. Blend: prev + (cur - prev) * alpha slides the position from where it was at
* the start of the last physics tick (prev) toward where it is now (cur), by
* the fraction alpha (BT.renderAlpha, 0 = tick just finished, almost 1 = next
* tick about to happen). This gives the true position at this exact render
* moment, so motion looks smooth between ticks instead of jumping.
* 2. Scale and shift: dividing by FIELD_RANGE gives a fraction from -1 to +1,
* multiplying by halfExtent stretches that to screen pixels, and adding
* halfExtent shifts the result so world (0, 0) lands at the screen center.
* Math.floor() then snaps to a whole pixel (you cannot draw half a pixel).
*
* Call it once with HALF_W for the x axis and once with HALF_H for the y axis.
*
* @param {number} prev - World coordinate at the start of the last tick.
* @param {number} cur - World coordinate now, after the last tick.
* @param {number} alpha - BT.renderAlpha blend fraction (0..1).
* @param {number} halfExtent - HALF_W for x, HALF_H for y.
* @returns {number} Whole-pixel screen coordinate.
*/
function function worldToScreen(prev: number, cur: number, alpha: number, halfExtent: number): numberConverts one axis of a world position into a screen pixel coordinate.
Two steps happen here:
1. Blend: prev + (cur - prev) * alpha slides the position from where it was at
the start of the last physics tick (prev) toward where it is now (cur), by
the fraction alpha (BT.renderAlpha, 0 = tick just finished, almost 1 = next
tick about to happen). This gives the true position at this exact render
moment, so motion looks smooth between ticks instead of jumping.
2. Scale and shift: dividing by FIELD_RANGE gives a fraction from -1 to +1,
multiplying by halfExtent stretches that to screen pixels, and adding
halfExtent shifts the result so world (0, 0) lands at the screen center.
Math.floor() then snaps to a whole pixel (you cannot draw half a pixel).
Call it once with HALF_W for the x axis and once with HALF_H for the y axis.worldToScreen(prev: number- World coordinate at the start of the last tick.prev, cur: number- World coordinate now, after the last tick.cur, alpha: number- BT.renderAlpha blend fraction (0..1).alpha, halfExtent: number- HALF_W for x, HALF_H for y.halfExtent) {
const const world: numberworld = prev: number- World coordinate at the start of the last tick.prev + (cur: number- World coordinate now, after the last tick.cur - prev: number- World coordinate at the start of the last tick.prev) * alpha: number- BT.renderAlpha blend fraction (0..1).alpha;
return Math.floor((const world: numberworld / const FIELD_RANGE: 10000FIELD_RANGE) * halfExtent: number- HALF_W for x, HALF_H for y.halfExtent + halfExtent: number- HALF_W for x, HALF_H for y.halfExtent);
}
/**
* Retro port of the classic macOS Flurry screensaver.
* Twelve spark attractors trace Lissajous orbit paths; PARTICLE_COUNT particles spiral
* around them via inverse-square gravity. Palette animation cycles a full rainbow every 15 seconds.
*
* @implements {IBTDemo}
*/
class class DemoRetro port of the classic macOS Flurry screensaver.
Twelve spark attractors trace Lissajous orbit paths; PARTICLE_COUNT particles spiral
around them via inverse-square gravity. Palette animation cycles a full rainbow every 15 seconds.Demo {
// The 256-slot palette used for all drawing. Filled in init(), updated every tick.
/** @type {Palette | null} */
Demo.palette: Palette | nullpalette = null;
// Total elapsed animation time, measured in seconds.
// Grows by exactly 1 / TARGET_FPS each tick (e.g. 1/60 when TARGET_FPS is 60).
// The spark position formula uses this as its clock - every tick the sparks move forward.
Demo.animTime: numberanimTime = 0;
// Current hue rotation offset in degrees (0..359).
// Increases by HUE_ADVANCE each tick. When it reaches 360 it wraps back to 0.
// Every palette slot that holds a particle or spark color uses this offset, so
// when huePhase changes by even a tiny amount, every color on screen shifts together.
Demo.huePhase: numberhuePhase = 0;
// Array of SPARK_COUNT spark objects (the invisible gravity-well attractors).
// Created in initSparks(). Each spark has:
// x, y - current world position
// prevX, prevY - world position as of the START of the most recent update() tick,
// before this tick's orbit math moved it. render() blends between prevX/prevY and
// x/y using BT.renderAlpha so sparks glide smoothly between physics ticks instead
// of jumping - see "Interpolating render state with renderAlpha" in the engine's
// docs/api-game-loop.md.
// vx, vy - instantaneous velocity (used as a hint when spawning particles)
// freqX, freqY - oscillation frequencies for the Lissajous orbit
// phaseX, phaseY - starting angles on the orbit path
// hueOffset - this spark's personal color angle on the rainbow (0..330 degrees)
Demo.sparks: {}sparks = [];
// Array of PARTICLE_COUNT particle objects. Created in initParticles(), reused forever.
// Dead particles are respawned rather than deleted. Each particle has:
// x, y - current world position
// prevX, prevY - world position as of the START of the most recent update() tick
// (see the sparks comment above for why render() needs this)
// vx, vy - current velocity
// age - how old the particle is (0.0 = newborn, 1.0 = about to die)
// ageRate - how fast it ages each tick (varies slightly per particle)
// hueIndex - which of the 8 color families it belongs to (0..7)
// alive - false means the particle is waiting to be respawned
Demo.particles: {}particles = [];
/**
* Heavy particle physics each tick; the timing chart helps spot frame budget pressure.
*
* @returns {Partial<HardwareSettings>}
*/
Demo.configure(): Partial<HardwareSettings>Heavy particle physics each tick; the timing chart helps spot frame budget pressure.configure() {
return {
targetFPS: numbertargetFPS: const TARGET_FPS: 60TARGET_FPS,
isOverlayTimingChartEnabled: booleanisOverlayTimingChartEnabled: true,
overlayTimingChartDiagnostics: stringoverlayTimingChartDiagnostics: 'rich',
isOverlayRendererDiagnosticsBarEnabled: booleanisOverlayRendererDiagnosticsBarEnabled: true,
overlayStyle: {
barPaletteIndex: number;
textPaletteIndex: number;
gapPaletteIndex: number;
}
overlayStyle: {
barPaletteIndex: numberbarPaletteIndex: const C_BG: 2C_BG,
textPaletteIndex: numbertextPaletteIndex: const C_TITLE: 3C_TITLE,
gapPaletteIndex: numbergapPaletteIndex: const C_BG: 2C_BG,
},
overlayTimingChartStyle: {
updateBarPaletteIndex: number;
renderBarPaletteIndex: number;
warningPaletteIndex: number;
errorPaletteIndex: number;
tagPaletteIndex: number;
}
overlayTimingChartStyle: {
updateBarPaletteIndex: numberupdateBarPaletteIndex: const C_TITLE: 3C_TITLE,
renderBarPaletteIndex: numberrenderBarPaletteIndex: const C_WHITE: 1C_WHITE,
warningPaletteIndex: numberwarningPaletteIndex: const C_SPARK_CORE: 6C_SPARK_CORE,
errorPaletteIndex: numbererrorPaletteIndex: const C_WHITE: 1C_WHITE,
tagPaletteIndex: numbertagPaletteIndex: const C_FPS: 5C_FPS,
},
};
}
/**
* Builds the palette and creates sparks and particles.
* Runs once before the first update() call.
*
* @returns {Promise<boolean>}
*/
async Demo.init(): Promise<boolean>Builds the palette and creates sparks and particles.
Runs once before the first update() call.init() {
console.log('[FlurryDemo] Initializing...');
// Build the palette
// We pre-fill all static (never-changing) slots now.
// The dynamic particle and spark color slots start as black and are
// overwritten every frame by updatePalette() inside update().
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;
... 106 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.paletteCreate: (size?: number) => PaletteCreates a standalone palette instance.paletteCreate(256);
// Static colors that never change during the demo.
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 for font.
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(4, 6, 12)); // Near-black deep-space blue.
this.Demo.palette: Palettepalette.Palette.set(index: number, color: Color32): voidWrites a color into a palette slot.set(const C_TITLE: 3C_TITLE, new new Color32(r?: number, g?: number, b?: number, a?: number): Color32Creates a clamped 8-bit RGBA color.Color32(255, 210, 80)); // Golden yellow for title.
this.Demo.palette: Palettepalette.Palette.set(index: number, color: Color32): voidWrites a color into a palette slot.set(const C_FPS: 5C_FPS, new new Color32(r?: number, g?: number, b?: number, a?: number): Color32Creates a clamped 8-bit RGBA color.Color32(55, 55, 75)); // Very dim for FPS counter.
this.Demo.palette: Palettepalette.Palette.set(index: number, color: Color32): voidWrites a color into a palette slot.set(const C_SPARK_CORE: 6C_SPARK_CORE, new new Color32(r?: number, g?: number, b?: number, a?: number): Color32Creates a clamped 8-bit RGBA color.Color32(255, 255, 255)); // White-hot spark center.
// Fill all 40 particle color slots with black as a placeholder.
// 8 hues × 5 tiers = 40 total. They will be overwritten on the very first update().
// We set them now so the palette has no uninitialized gaps.
for (let let i: numberi = 0; let i: numberi < 8 * 5; let i: numberi++) {
this.Demo.palette: Palettepalette.Palette.set(index: number, color: Color32): voidWrites a color into a palette slot.set(const C_PARTICLE_BASE: 10C_PARTICLE_BASE + let i: numberi, new new Color32(r?: number, g?: number, b?: number, a?: number): Color32Creates a clamped 8-bit RGBA color.Color32(0, 0, 0));
}
// Fill all 24 spark color slots with black as a placeholder.
// 12 bright slots (one per spark) and 12 halo slots (one per spark).
for (let let i: numberi = 0; let i: numberi < const SPARK_COUNT: 12SPARK_COUNT; let i: numberi++) {
this.Demo.palette: Palettepalette.Palette.set(index: number, color: Color32): voidWrites a color into a palette slot.set(const C_SPARK_BRIGHT: 100C_SPARK_BRIGHT + let i: numberi, new new Color32(r?: number, g?: number, b?: number, a?: number): Color32Creates a clamped 8-bit RGBA color.Color32(0, 0, 0));
this.Demo.palette: Palettepalette.Palette.set(index: number, color: Color32): voidWrites a color into a palette slot.set(const C_SPARK_HALO: 112C_SPARK_HALO + let i: numberi, new new Color32(r?: number, g?: number, b?: number, a?: number): Color32Creates a clamped 8-bit RGBA color.Color32(0, 0, 0));
}
// Activate the palette. From this point on, all drawing uses these color slots.
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;
... 106 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);
// Create sparks
// Sparks are the invisible gravity wells that all particles orbit around.
this.Demo.initSparks(): voidCreates all 12 spark objects from SPARK_TABLE and positions them at time 0.initSparks();
// Create particles
// All PARTICLE_COUNT particles are created here and reused for the life of the demo.
this.Demo.initParticles(): voidCreates all PARTICLE_COUNT particle objects and spreads them across random ages.
Spreading ages (called "staggering") is important: if all particles started at
age 0 together, they would all reach age 1 at the same moment and all die together.
The screen would flash blank for one frame while they all respawned. Staggering
avoids this by giving each particle a different head start.initParticles();
// Fill animated palette slots once without advancing physics (animTime / sparks /
// particles). Calling update() here would move the simulation before the engine's
// timing is available; updatePalette() only writes colors.
this.Demo.updatePalette(): voidRewrites all dynamic palette slots for the current frame.
This is "palette animation": by changing what color each slot number means,
everything drawn with that slot number changes color instantly.
Two groups of slots are updated:
1. Particle color ramp (40 slots): 8 hue bands × 5 brightness tiers.
All hues rotate with huePhase, cycling through the full rainbow over 15 seconds.
2. Spark colors (24 slots): one bright + one halo slot per spark.
Each spark has its own hue offset, so all 12 display different rainbow colors.updatePalette();
console.log('[FlurryDemo] Initialized');
return true;
}
/**
* Runs TARGET_FPS times per second (target frame rate, e.g. 60 by default). Advances physics and rewrites palette colors.
* All Color32 work happens here; render() only ever uses slot numbers.
*/
Demo.update(): voidRuns TARGET_FPS times per second (target frame rate, e.g. 60 by default). Advances physics and rewrites palette colors.
All Color32 work happens here; render() only ever uses slot numbers.update() {
// Advance time by one fixed-step duration in seconds.
this.Demo.animTime: numberanimTime += 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;
... 106 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.deltaSeconds: numberFixed-step seconds per update tick.
Equivalent to `1 / BT.targetFPS` when `BT.targetFPS` is finite and positive.
Falls back to `1 / 60` when target FPS is non-finite or non-positive.deltaSeconds;
// Rotate the global hue. The % operator wraps the angle back to 0 at 360.
this.Demo.huePhase: numberhuePhase = (this.Demo.huePhase: numberhuePhase + const HUE_ADVANCE: 0.4HUE_ADVANCE) % 360;
// Move all 12 sparks along their orbital Lissajous paths.
this.Demo.updateSparks(): voidMoves all 12 sparks along their Lissajous orbital paths.
Also computes spark velocity (the rate of change of position), which is
used in spawnParticle() as a directional hint for newly born particles.updateSparks();
// Update physics for every particle.
// Alive particles get gravity, drag, and position update.
// Dead particles are immediately respawned near a random spark.
for (let let i: numberi = 0; let i: numberi < const PARTICLE_COUNT: 800PARTICLE_COUNT; let i: numberi++) {
if (this.Demo.particles: {}particles[let i: numberi].alive) {
this.Demo.updateParticle(p: object): voidMoves one particle forward one tick: gravity, drag, speed cap, position.updateParticle(this.Demo.particles: {}particles[let i: numberi]);
} else {
this.Demo.spawnParticle(p: object): voidResets a dead particle: places it near a random spark and gives it a fresh start.spawnParticle(this.Demo.particles: {}particles[let i: numberi]);
}
}
// Recompute all dynamic palette colors for this frame.
this.Demo.updatePalette(): voidRewrites all dynamic palette slots for the current frame.
This is "palette animation": by changing what color each slot number means,
everything drawn with that slot number changes color instantly.
Two groups of slots are updated:
1. Particle color ramp (40 slots): 8 hue bands × 5 brightness tiers.
All hues rotate with huePhase, cycling through the full rainbow over 15 seconds.
2. Spark colors (24 slots): one bright + one halo slot per spark.
Each spark has its own hue offset, so all 12 display different rainbow colors.updatePalette();
}
/**
* Draws the current frame. Only palette slot numbers appear here - no Color32 objects.
* All color decisions were already made in update() and stored in the palette.
*/
Demo.render(): voidDraws the current frame. Only palette slot numbers appear here - no Color32 objects.
All color decisions were already made in update() and stored in the palette.render() {
// Wipe every pixel to the background color before drawing anything new.
// Without this, the previous frame's particles would remain visible as ghost trails.
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;
... 106 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 all particles (two passes: dim old ones first, bright young ones on top).
this.Demo.renderParticles(): voidDraws all alive particles in two separate passes.
Why two passes instead of one?
We want dim old particles to appear underneath bright young ones.
The easiest way is to draw all old particles first (pass 1), then all young
particles on top (pass 2). Any young particle that overlaps an old one will
simply paint over it, which is the correct layering order.
This avoids sorting the particle array (which would be much slower).
Pass 1 - old particles (tier 3 and 4): drawn as 1×1 single pixels.
Pass 2 - young particles (tier 0, 1, 2): drawn as 2×2 filled rectangles.renderParticles();
// Draw the 12 spark attractors on top of everything.
this.Demo.renderSparks(): voidDraws all 12 sparks as three-layer colored squares to suggest a glowing light source.
Three layers are stacked from largest (drawn first / underneath) to smallest (on top):
Layer 1: 5×5 pixels, dim halo color - the outer glow ring.
Layer 2: 3×3 pixels, bright body color - the vivid colored core.
Layer 3: 1×1 pixel, white - the white-hot center point.
Drawing larger shapes first and smaller shapes on top is how layered "glow" effects
are built without any actual blending or transparency.renderSparks();
// Palette strip along the very bottom of the screen.
// Shows the live particle and spark color slots as small colored squares.
// As huePhase advances, watch this strip cycle through the entire rainbow.
this.Demo.renderPaletteStrip(): voidDraws two thin rows of colored squares along the very bottom of the screen.
Top row - 12 spark-bright slots:
Each of the 12 sparks gets one rectangle ~26 px wide.
All 12 span different hues (the sparks are 30 degrees apart on the color wheel),
so this row always looks like a full rainbow no matter where huePhase is.
Bottom row - 40 particle slots (8 hues x 5 brightness tiers):
The 40 particle palette entries are displayed left to right.
Each group of 5 squares (40 px wide) is one hue band, going from bright to dim.
As the global hue rotates, this entire row slides through the rainbow in real time.
Think of these rows as a "legend" for the colors currently on screen.renderPaletteStrip();
}
/**
* Creates all 12 spark objects from SPARK_TABLE and positions them at time 0.
*/
Demo.initSparks(): voidCreates all 12 spark objects from SPARK_TABLE and positions them at time 0.initSparks() {
for (let let i: numberi = 0; let i: numberi < const SPARK_COUNT: 12SPARK_COUNT; let i: numberi++) {
// Destructuring: pull the five values out of this row of SPARK_TABLE.
const [const freqX: anyfreqX, const freqY: anyfreqY, const phaseX: anyphaseX, const phaseY: anyphaseY, const hueOffset: anyhueOffset] = const SPARK_TABLE: {}SPARK_TABLE[let i: numberi];
this.Demo.sparks: {}sparks.push({
x: numberx: 0, // Current world x position (set by updateSparks).
y: numbery: 0, // Current world y position.
prevX: numberprevX: 0, // World x position at the start of the last tick (for render interpolation).
prevY: numberprevY: 0, // World y position at the start of the last tick.
vx: numbervx: 0, // Instantaneous velocity on x (used for spawn direction hint).
vy: numbervy: 0, // Instantaneous velocity on y.
freqX: anyfreqX, // How fast the spark oscillates horizontally.
freqY: anyfreqY, // How fast the spark oscillates vertically.
phaseX: anyphaseX, // Starting angle on the x sine wave.
phaseY: anyphaseY, // Starting angle on the y cosine wave.
hueOffset: anyhueOffset, // This spark's personal color angle on the rainbow wheel.
});
}
// Run one updateSparks() so all sparks have correct positions before particles spawn.
this.Demo.updateSparks(): voidMoves all 12 sparks along their Lissajous orbital paths.
Also computes spark velocity (the rate of change of position), which is
used in spawnParticle() as a directional hint for newly born particles.updateSparks();
// The first updateSparks() call above moved every spark away from its placeholder
// (0, 0), which would otherwise look like every spark render-interpolating in from
// the world's center on the very first frame. Snap prevX/prevY to match so the
// first render draws sparks in the right place with no bogus streak.
for (let let i: numberi = 0; let i: numberi < const SPARK_COUNT: 12SPARK_COUNT; let i: numberi++) {
this.Demo.sparks: {}sparks[let i: numberi].prevX = this.Demo.sparks: {}sparks[let i: numberi].x;
this.Demo.sparks: {}sparks[let i: numberi].prevY = this.Demo.sparks: {}sparks[let i: numberi].y;
}
}
/**
* Creates all PARTICLE_COUNT particle objects and spreads them across random ages.
*
* Spreading ages (called "staggering") is important: if all particles started at
* age 0 together, they would all reach age 1 at the same moment and all die together.
* The screen would flash blank for one frame while they all respawned. Staggering
* avoids this by giving each particle a different head start.
*/
Demo.initParticles(): voidCreates all PARTICLE_COUNT particle objects and spreads them across random ages.
Spreading ages (called "staggering") is important: if all particles started at
age 0 together, they would all reach age 1 at the same moment and all die together.
The screen would flash blank for one frame while they all respawned. Staggering
avoids this by giving each particle a different head start.initParticles() {
for (let let i: numberi = 0; let i: numberi < const PARTICLE_COUNT: 800PARTICLE_COUNT; let i: numberi++) {
// Pre-create each particle with dummy values. spawnParticle() will overwrite them.
// We push the object first so it exists in the array before spawnParticle() runs.
this.Demo.particles: {}particles.push({
x: numberx: 0,
y: numbery: 0,
prevX: numberprevX: 0,
prevY: numberprevY: 0,
vx: numbervx: 0,
vy: numbervy: 0,
age: numberage: 0,
ageRate: numberageRate: 0.002,
hueIndex: numberhueIndex: 0,
alive: booleanalive: false,
});
// Set real position, velocity, hue, and age rate using the full spawn logic.
this.Demo.spawnParticle(p: object): voidResets a dead particle: places it near a random spark and gives it a fresh start.spawnParticle(this.Demo.particles: {}particles[let i: numberi]);
// Override age with a random value so this particle starts mid-life.
// BT.random is the engine's shared random number generator.
// Its next() method returns a decimal from 0.0 (just born) up to, but never reaching, 1.0 (almost dead).
this.Demo.particles: {}particles[let i: numberi].age = 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;
... 106 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.random: RandomDefault engine PRNG (live reference - not a copy).
Time-seeded when the engine singleton is created. Call
{@link
BT.randomSeed
}
for a reproducible run. Mutating the instance (for example `BT.random.int(10)`)
advances the shared stream.random.Random.next(): numberReturns the next pseudo-random float in [0, 1).next();
}
}
/**
* Moves one particle forward one tick: gravity, drag, speed cap, position.
*
* @param {object} p - The particle to update.
*/
Demo.updateParticle(p: object): voidMoves one particle forward one tick: gravity, drag, speed cap, position.updateParticle(p: object- The particle to update.p) {
// Advance the particle's age by its personal ageRate (about 0.002 per tick).
// age counts from 0.0 (just born) toward 1.0 (end of life).
// Think of it like a candle burning down - each tick uses a little more.
p: object- The particle to update.p.age += p: object- The particle to update.p.ageRate;
if (p: object- The particle to update.p.age >= 1.0) {
// The particle has reached the end of its life. Mark it dead so that the
// main update() loop can respawn it in the next iteration.
p: object- The particle to update.p.alive = false;
return; // Nothing more to do for a dead particle this tick.
}
// Gravity from all 12 sparks
// We add up the gravitational pull from every spark.
// Each spark pulls the particle a little bit; the total is the net acceleration.
let let ax: numberax = 0; // Accumulated acceleration on the x axis.
let let ay: numberay = 0; // Accumulated acceleration on the y axis.
for (let let s: numbers = 0; let s: numbers < const SPARK_COUNT: 12SPARK_COUNT; let s: numbers++) {
const const spark: anyspark = this.Demo.sparks: {}sparks[let s: numbers];
// Direction vector from this particle toward the spark.
// Positive dx means the spark is to the right; positive dy means it is below.
const const dx: numberdx = const spark: anyspark.x - p: object- The particle to update.p.x;
const const dy: numberdy = const spark: anyspark.y - p: object- The particle to update.p.y;
// Squared distance. We add 250000 (= 500^2) as a "softening factor"
// this prevents the force from becoming infinite if the particle sits
// exactly on top of the spark.
const const distSq: numberdistSq = const dx: numberdx * const dx: numberdx + const dy: numberdy * const dy: numberdy + 250000;
// Actual distance (square root of the squared distance).
// We need this to turn (dx, dy) into a normalized direction vector (length = 1).
const const dist: anydist = Math.sqrt(const distSq: numberdistSq);
// Gravitational force: F = G / r^2 (inverse-square law).
// At double the distance, the force is 4x weaker.
const const force: numberforce = const GRAVITY_CONST: 1500000GRAVITY_CONST / const distSq: numberdistSq;
// Add this spark's contribution to the total acceleration.
// Dividing dx by dist normalizes it (makes the vector length = 1),
// then multiplying by force gives the correct magnitude.
let ax: numberax += (const dx: numberdx / const dist: anydist) * const force: numberforce;
let ay: numberay += (const dy: numberdy / const dist: anydist) * const force: numberforce;
}
// Apply acceleration to velocity. Think: velocity is like a car's speed,
// and acceleration is like the gas pedal adding more speed each tick.
p: object- The particle to update.p.vx += let ax: numberax;
p: object- The particle to update.p.vy += let ay: numberay;
// Drag
// Multiply velocity by DRAG_FACTOR (~0.9950) each tick.
// This slowly bleeds off speed, like air resistance.
// Without drag, particles would spiral in, slingshot around, and fly away forever.
p: object- The particle to update.p.vx *= const DRAG_FACTOR: numberDRAG_FACTOR;
p: object- The particle to update.p.vy *= const DRAG_FACTOR: numberDRAG_FACTOR;
// Speed cap
// If the particle is moving faster than SPEED_CAP, scale velocity back down.
// vx^2 + vy^2 is the squared speed (we avoid a sqrt here for performance).
const const speedSq: numberspeedSq = p: object- The particle to update.p.vx * p: object- The particle to update.p.vx + p: object- The particle to update.p.vy * p: object- The particle to update.p.vy;
if (const speedSq: numberspeedSq > const SPEED_CAP: 600SPEED_CAP * const SPEED_CAP: 600SPEED_CAP) {
// Compute actual speed and scale velocity to the cap.
const const speed: anyspeed = Math.sqrt(const speedSq: numberspeedSq);
p: object- The particle to update.p.vx = (p: object- The particle to update.p.vx / const speed: anyspeed) * const SPEED_CAP: 600SPEED_CAP;
p: object- The particle to update.p.vy = (p: object- The particle to update.p.vy / const speed: anyspeed) * const SPEED_CAP: 600SPEED_CAP;
}
// Move the particle
// Remember where the particle started this tick before moving it, so render()
// can blend smoothly between "was here" and "is here" (see the prevX/prevY
// comment on the particles field above).
p: object- The particle to update.p.prevX = p: object- The particle to update.p.x;
p: object- The particle to update.p.prevY = p: object- The particle to update.p.y;
// Velocity (units/tick) added to position gives the new position.
p: object- The particle to update.p.x += p: object- The particle to update.p.vx;
p: object- The particle to update.p.y += p: object- The particle to update.p.vy;
// Soft boundary
// If the particle has escaped more than 20% beyond the field boundary,
// gently nudge it back toward center rather than letting it drift off forever.
// 1.2^2 = 1.44, so we check against FIELD_RANGE * 1.2.
if (p: object- The particle to update.p.x * p: object- The particle to update.p.x + p: object- The particle to update.p.y * p: object- The particle to update.p.y > const FIELD_RANGE: 10000FIELD_RANGE * const FIELD_RANGE: 10000FIELD_RANGE * 1.44) {
// Shrink position toward center (0,0).
p: object- The particle to update.p.x *= 0.9;
p: object- The particle to update.p.y *= 0.9;
// Kill some velocity so it does not immediately escape again.
p: object- The particle to update.p.vx *= 0.5;
p: object- The particle to update.p.vy *= 0.5;
}
}
/**
* Resets a dead particle: places it near a random spark and gives it a fresh start.
*
* @param {object} p - The particle object to reinitialize.
*/
Demo.spawnParticle(p: object): voidResets a dead particle: places it near a random spark and gives it a fresh start.spawnParticle(p: object- The particle object to reinitialize.p) {
// Pick a random spark to be born near.
// BT.random.int() with one argument counts from 0, so int(SPARK_COUNT) lands on any spark position in
// the array.
const const sparkIndex: numbersparkIndex = 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;
... 106 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.random: RandomDefault engine PRNG (live reference - not a copy).
Time-seeded when the engine singleton is created. Call
{@link
BT.randomSeed
}
for a reproducible run. Mutating the instance (for example `BT.random.int(10)`)
advances the shared stream.random.Random.int(minOrMaxExclusive: number, maxExclusive?: number): numberReturns a pseudo-random integer in [0, maxExclusive) or [min, maxExclusive).int(const SPARK_COUNT: 12SPARK_COUNT);
const const spark: anyspark = this.Demo.sparks: {}sparks[const sparkIndex: numbersparkIndex];
// Place the particle close to the spark, with a small random scatter.
// float() is the decimal version of int(). Handing it a negative low end and a positive high end scatters the
// particle on either side of the spark.
p: object- The particle object to reinitialize.p.x = const spark: anyspark.x + 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;
... 106 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.random: RandomDefault engine PRNG (live reference - not a copy).
Time-seeded when the engine singleton is created. Call
{@link
BT.randomSeed
}
for a reproducible run. Mutating the instance (for example `BT.random.int(10)`)
advances the shared stream.random.Random.float(min: number, max: number): numberReturns the next pseudo-random float in [min, max).float(-const SPAWN_SCATTER: 30SPAWN_SCATTER, const SPAWN_SCATTER: 30SPAWN_SCATTER);
p: object- The particle object to reinitialize.p.y = const spark: anyspark.y + 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;
... 106 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.random: RandomDefault engine PRNG (live reference - not a copy).
Time-seeded when the engine singleton is created. Call
{@link
BT.randomSeed
}
for a reproducible run. Mutating the instance (for example `BT.random.int(10)`)
advances the shared stream.random.Random.float(min: number, max: number): numberReturns the next pseudo-random float in [min, max).float(-const SPAWN_SCATTER: 30SPAWN_SCATTER, const SPAWN_SCATTER: 30SPAWN_SCATTER);
// Snap prevX/prevY to the same spot as the new x/y. Without this, render()
// would blend from wherever this particle died (maybe clear across the
// screen) to its brand-new spawn point, drawing a streak that was never
// really there.
p: object- The particle object to reinitialize.p.prevX = p: object- The particle object to reinitialize.p.x;
p: object- The particle object to reinitialize.p.prevY = p: object- The particle object to reinitialize.p.y;
// Give the particle a random initial velocity in a random direction.
// angle() picks any direction on the compass, measured in radians. (Radians are another way to measure angles:
// 2*PI radians, about 6.28, is a full 360-degree turn - and that is exactly the range angle() draws from.)
const const angle: numberangle = 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;
... 106 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.random: RandomDefault engine PRNG (live reference - not a copy).
Time-seeded when the engine singleton is created. Call
{@link
BT.randomSeed
}
for a reproducible run. Mutating the instance (for example `BT.random.int(10)`)
advances the shared stream.random.Random.angle(): numberReturns a uniform angle in [0, 2π) radians.angle();
// Random speed between 50% and 150% of STREAM_SPEED.
const const speed: numberspeed = const STREAM_SPEED: 15STREAM_SPEED * 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;
... 106 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.random: RandomDefault engine PRNG (live reference - not a copy).
Time-seeded when the engine singleton is created. Call
{@link
BT.randomSeed
}
for a reproducible run. Mutating the instance (for example `BT.random.int(10)`)
advances the shared stream.random.Random.float(min: number, max: number): numberReturns the next pseudo-random float in [min, max).float(0.5, 1.5);
// Math.cos(angle) is the horizontal part of the direction (left/right).
// Math.sin(angle) is the vertical part of the direction (up/down).
// Together they form a unit vector (length = 1) pointing in the chosen direction;
// multiplying by speed scales it to the right magnitude.
// We also add 40% of the spark's own velocity so particles stream behind it
// as it moves, rather than erupting in a stationary starburst.
p: object- The particle object to reinitialize.p.vx = Math.cos(const angle: numberangle) * const speed: numberspeed + const spark: anyspark.vx * 0.4;
p: object- The particle object to reinitialize.p.vy = Math.sin(const angle: numberangle) * const speed: numberspeed + const spark: anyspark.vy * 0.4;
// Assign this particle to one of the 8 hue color bands.
// There are 12 sparks but only 8 hue bands, so some bands are shared by two sparks.
// % is the remainder operator: 10 % 8 = 2, 11 % 8 = 3. It "wraps around" at 8,
// cycling from 0 through 7 no matter how large sparkIndex gets.
p: object- The particle object to reinitialize.p.hueIndex = const sparkIndex: numbersparkIndex % 8;
// Each particle ages at a slightly different rate so they don't all die together.
// 0.0018 to 0.0025 per tick gives a lifetime of roughly 400..555 ticks.
// At 60 FPS that is 6.7 to 9.3 seconds of life per particle.
p: object- The particle object to reinitialize.p.ageRate = 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;
... 106 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.random: RandomDefault engine PRNG (live reference - not a copy).
Time-seeded when the engine singleton is created. Call
{@link
BT.randomSeed
}
for a reproducible run. Mutating the instance (for example `BT.random.int(10)`)
advances the shared stream.random.Random.float(min: number, max: number): numberReturns the next pseudo-random float in [min, max).float(0.0018, 0.0025);
p: object- The particle object to reinitialize.p.age = 0;
p: object- The particle object to reinitialize.p.alive = true;
}
/**
* Moves all 12 sparks along their Lissajous orbital paths.
* Also computes spark velocity (the rate of change of position), which is
* used in spawnParticle() as a directional hint for newly born particles.
*/
Demo.updateSparks(): voidMoves all 12 sparks along their Lissajous orbital paths.
Also computes spark velocity (the rate of change of position), which is
used in spawnParticle() as a directional hint for newly born particles.updateSparks() {
for (let let i: numberi = 0; let i: numberi < const SPARK_COUNT: 12SPARK_COUNT; let i: numberi++) {
const const spark: anyspark = this.Demo.sparks: {}sparks[let i: numberi];
// Remember this tick's starting position before the orbit math below moves
// the spark, so render() has an "old" and "new" position to blend between.
const spark: anyspark.prevX = const spark: anyspark.x;
const spark: anyspark.prevY = const spark: anyspark.y;
// Lissajous orbit: x follows a sine wave, y follows a cosine wave.
// Slightly different frequencies (freqX vs freqY) mean the path slowly
// drifts and fills in, never quite repeating itself.
// FIELD_RANGE * 0.8: sparks orbit within 80% of the field - they stay on screen.
const spark: anyspark.x = Math.sin(this.Demo.animTime: numberanimTime * const spark: anyspark.freqX + const spark: anyspark.phaseX) * const FIELD_RANGE: 10000FIELD_RANGE * 0.8;
const spark: anyspark.y = Math.cos(this.Demo.animTime: numberanimTime * const spark: anyspark.freqY + const spark: anyspark.phaseY) * const FIELD_RANGE: 10000FIELD_RANGE * 0.8;
// Instantaneous velocity = the mathematical derivative of the position formula.
// d/dt [sin(t * f + p)] = cos(t * f + p) * f
// d/dt [cos(t * f + p)] = -sin(t * f + p) * f
// Dividing by TARGET_FPS converts from "per second" to "per tick" using the
// same constant that drives animTime in update(), so if TARGET_FPS ever
// changes, both the clock and this velocity scale stay in sync.
const spark: anyspark.vx =
(Math.cos(this.Demo.animTime: numberanimTime * const spark: anyspark.freqX + const spark: anyspark.phaseX) * const spark: anyspark.freqX * const FIELD_RANGE: 10000FIELD_RANGE * 0.8) / const TARGET_FPS: 60TARGET_FPS;
const spark: anyspark.vy =
(-Math.sin(this.Demo.animTime: numberanimTime * const spark: anyspark.freqY + const spark: anyspark.phaseY) * const spark: anyspark.freqY * const FIELD_RANGE: 10000FIELD_RANGE * 0.8) / const TARGET_FPS: 60TARGET_FPS;
}
}
/**
* Rewrites all dynamic palette slots for the current frame.
* This is "palette animation": by changing what color each slot number means,
* everything drawn with that slot number changes color instantly.
*
* Two groups of slots are updated:
* 1. Particle color ramp (40 slots): 8 hue bands × 5 brightness tiers.
* All hues rotate with huePhase, cycling through the full rainbow over 15 seconds.
* 2. Spark colors (24 slots): one bright + one halo slot per spark.
* Each spark has its own hue offset, so all 12 display different rainbow colors.
*/
Demo.updatePalette(): voidRewrites all dynamic palette slots for the current frame.
This is "palette animation": by changing what color each slot number means,
everything drawn with that slot number changes color instantly.
Two groups of slots are updated:
1. Particle color ramp (40 slots): 8 hue bands × 5 brightness tiers.
All hues rotate with huePhase, cycling through the full rainbow over 15 seconds.
2. Spark colors (24 slots): one bright + one halo slot per spark.
Each spark has its own hue offset, so all 12 display different rainbow colors.updatePalette() {
// Particle color ramp
for (let let h: numberh = 0; let h: numberh < 8; let h: numberh++) {
// The base angle for this hue band: evenly spread around the color wheel.
// h=0 → 0°, h=1 → 45°, h=2 → 90°, ..., h=7 → 315°.
// Adding huePhase rotates all hue bands together like a spinning color wheel.
// % 360 keeps the angle in the valid range.
const const hue: numberhue = ((let h: numberh / 8) * 360 + this.Demo.huePhase: numberhuePhase) % 360;
for (let let t: numbert = 0; let t: numbert < 5; let t: numbert++) {
// Color32.fromHSL(hue, saturation, lightness):
// hue 0..360: position on the color wheel (0=red, 120=green, 240=blue)
// saturation 0..100: how vivid the color is (90 = highly saturated)
// lightness 0..100: brightness (0=black, 50=pure color, 100=white)
// TIER_LIGHTNESS[t] gives the brightness for this age tier.
this.Demo.palette: Palette | nullpalette.Palette.set(index: number, color: Color32): voidWrites a color into a palette slot.set(const C_PARTICLE_BASE: 10C_PARTICLE_BASE + let h: numberh * 5 + let t: numbert, class Color32Mutable 32-bit RGBA color value with 8-bit channels.Color32.Color32.fromHSL(h: number, s: number, l: number, a?: number): Color32Creates a color from HSL values.fromHSL(const hue: numberhue, 90, const TIER_LIGHTNESS: {}TIER_LIGHTNESS[let t: numbert]));
}
}
// Spark colors
// Each spark has a personal hueOffset (0°, 30°, 60°, ..., 330°) so they each
// show a different color on the rainbow at the same time.
// Adding the global huePhase makes all spark colors cycle along with the particles.
for (let let i: numberi = 0; let i: numberi < const SPARK_COUNT: 12SPARK_COUNT; let i: numberi++) {
const const sparkHue: numbersparkHue = (this.Demo.huePhase: numberhuePhase + this.Demo.sparks: {}sparks[let i: numberi].hueOffset) % 360;
// Bright outer body of the spark: vivid, high lightness.
this.Demo.palette: Palette | nullpalette.Palette.set(index: number, color: Color32): voidWrites a color into a palette slot.set(const C_SPARK_BRIGHT: 100C_SPARK_BRIGHT + let i: numberi, class Color32Mutable 32-bit RGBA color value with 8-bit channels.Color32.Color32.fromHSL(h: number, s: number, l: number, a?: number): Color32Creates a color from HSL values.fromHSL(const sparkHue: numbersparkHue, 95, 78));
// Dim halo ring: same hue, but lower saturation and lightness.
// This gives the spark a slightly "glowing" appearance.
this.Demo.palette: Palette | nullpalette.Palette.set(index: number, color: Color32): voidWrites a color into a palette slot.set(const C_SPARK_HALO: 112C_SPARK_HALO + let i: numberi, class Color32Mutable 32-bit RGBA color value with 8-bit channels.Color32.Color32.fromHSL(h: number, s: number, l: number, a?: number): Color32Creates a color from HSL values.fromHSL(const sparkHue: numbersparkHue, 60, 38));
}
}
/**
* Draws all alive particles in two separate passes.
*
* Why two passes instead of one?
* We want dim old particles to appear underneath bright young ones.
* The easiest way is to draw all old particles first (pass 1), then all young
* particles on top (pass 2). Any young particle that overlaps an old one will
* simply paint over it, which is the correct layering order.
* This avoids sorting the particle array (which would be much slower).
*
* Pass 1 - old particles (tier 3 and 4): drawn as 1×1 single pixels.
* Pass 2 - young particles (tier 0, 1, 2): drawn as 2×2 filled rectangles.
*/
Demo.renderParticles(): voidDraws all alive particles in two separate passes.
Why two passes instead of one?
We want dim old particles to appear underneath bright young ones.
The easiest way is to draw all old particles first (pass 1), then all young
particles on top (pass 2). Any young particle that overlaps an old one will
simply paint over it, which is the correct layering order.
This avoids sorting the particle array (which would be much slower).
Pass 1 - old particles (tier 3 and 4): drawn as 1×1 single pixels.
Pass 2 - young particles (tier 0, 1, 2): drawn as 2×2 filled rectangles.renderParticles() {
// How far we are, right now, between the last completed physics tick and the
// next one. Every particle blends its prevX/prevY toward its x/y by this same
// fraction, giving each one a true render-time position instead of only its
// last-tick position (same idea as renderSparks() above).
const const alpha: numberalpha = 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;
... 106 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.renderAlpha: numberFractional progress between the last completed fixed update and the next.
Intended for interpolating render state between fixed-update steps.renderAlpha;
// Pass 1: old, dim particles as single 1×1 pixels
for (let let i: numberi = 0; let i: numberi < const PARTICLE_COUNT: 800PARTICLE_COUNT; let i: numberi++) {
const const p: anyp = this.Demo.particles: {}particles[let i: numberi];
// "continue" means: stop processing this particle and jump straight
// to the next one. It is like saying "skip this one".
if (!const p: anyp.alive) {
continue; // Skip dead particles - they have no position to draw.
}
// Convert age (0..1) to a tier index (0..4).
// p.age * 5 maps the 0..1 range to 0..5.
// Math.floor() rounds down to the nearest whole number: 0.7 * 5 = 3.5 -> 3.
// Math.min(4, ...) clamps the result so it never exceeds 4 (the last tier).
const const tier: anytier = Math.min(4, Math.floor(const p: anyp.age * 5));
// Pass 1 only draws particles that are in their last two tiers (3 or 4).
// Tier 3 is "aging" (lightness 36) and tier 4 is "near-dead" (lightness 20).
// Tiers 0, 1, 2 are handled in pass 2 - skip them here.
if (const tier: anytier < 3) {
continue;
}
// Convert the particle's blended world position to screen pixels
// (worldToScreen() above explains the blend and the conversion formula).
const const sx: numbersx = function worldToScreen(prev: number, cur: number, alpha: number, halfExtent: number): numberConverts one axis of a world position into a screen pixel coordinate.
Two steps happen here:
1. Blend: prev + (cur - prev) * alpha slides the position from where it was at
the start of the last physics tick (prev) toward where it is now (cur), by
the fraction alpha (BT.renderAlpha, 0 = tick just finished, almost 1 = next
tick about to happen). This gives the true position at this exact render
moment, so motion looks smooth between ticks instead of jumping.
2. Scale and shift: dividing by FIELD_RANGE gives a fraction from -1 to +1,
multiplying by halfExtent stretches that to screen pixels, and adding
halfExtent shifts the result so world (0, 0) lands at the screen center.
Math.floor() then snaps to a whole pixel (you cannot draw half a pixel).
Call it once with HALF_W for the x axis and once with HALF_H for the y axis.worldToScreen(const p: anyp.prevX, const p: anyp.x, const alpha: numberalpha, const HALF_W: numberHALF_W);
const const sy: numbersy = function worldToScreen(prev: number, cur: number, alpha: number, halfExtent: number): numberConverts one axis of a world position into a screen pixel coordinate.
Two steps happen here:
1. Blend: prev + (cur - prev) * alpha slides the position from where it was at
the start of the last physics tick (prev) toward where it is now (cur), by
the fraction alpha (BT.renderAlpha, 0 = tick just finished, almost 1 = next
tick about to happen). This gives the true position at this exact render
moment, so motion looks smooth between ticks instead of jumping.
2. Scale and shift: dividing by FIELD_RANGE gives a fraction from -1 to +1,
multiplying by halfExtent stretches that to screen pixels, and adding
halfExtent shifts the result so world (0, 0) lands at the screen center.
Math.floor() then snaps to a whole pixel (you cannot draw half a pixel).
Call it once with HALF_W for the x axis and once with HALF_H for the y axis.worldToScreen(const p: anyp.prevY, const p: anyp.y, const alpha: numberalpha, const HALF_H: numberHALF_H);
// Skip any particle whose screen position is outside the visible area.
// Drawing outside the canvas boundaries would cause an engine error.
if (const sx: numbersx < 0 || const sx: numbersx >= const DISPLAY_W: 320DISPLAY_W || const sy: numbersy < 0 || const sy: numbersy >= const DISPLAY_H: 240DISPLAY_H) {
continue;
}
// Look up the palette slot for this particle's hue and brightness tier.
// Formula: base + (hue band index × 5 slots per band) + tier within band.
// Example: hueIndex=3, tier=4 -> slot 10 + 3*5 + 4 = slot 29.
const const slot: anyslot = const C_PARTICLE_BASE: 10C_PARTICLE_BASE + const p: anyp.hueIndex * 5 + const tier: anytier;
// Draw a single pixel at the screen position using the computed color slot.
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;
... 106 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(const sx: numbersx, const sy: numbersy), const slot: anyslot);
}
// Pass 2: young, bright particles as 2×2 filled rectangles
for (let let i: numberi = 0; let i: numberi < const PARTICLE_COUNT: 800PARTICLE_COUNT; let i: numberi++) {
const const p: anyp = this.Demo.particles: {}particles[let i: numberi];
if (!const p: anyp.alive) {
continue; // Skip dead particles.
}
// Recompute the tier for this pass. Each particle is only handled in one pass,
// so checking the tier again here costs very little and keeps both passes
// independent - no shared state needed between the two loops.
const const tier: anytier = Math.min(4, Math.floor(const p: anyp.age * 5));
// This pass only draws young particles (tiers 0, 1, and 2).
// Old particles (tiers 3 and 4) were already drawn as pixels in pass 1.
if (const tier: anytier >= 3) {
continue;
}
// Same world-to-screen conversion as pass 1 (see worldToScreen() above).
const const sx: numbersx = function worldToScreen(prev: number, cur: number, alpha: number, halfExtent: number): numberConverts one axis of a world position into a screen pixel coordinate.
Two steps happen here:
1. Blend: prev + (cur - prev) * alpha slides the position from where it was at
the start of the last physics tick (prev) toward where it is now (cur), by
the fraction alpha (BT.renderAlpha, 0 = tick just finished, almost 1 = next
tick about to happen). This gives the true position at this exact render
moment, so motion looks smooth between ticks instead of jumping.
2. Scale and shift: dividing by FIELD_RANGE gives a fraction from -1 to +1,
multiplying by halfExtent stretches that to screen pixels, and adding
halfExtent shifts the result so world (0, 0) lands at the screen center.
Math.floor() then snaps to a whole pixel (you cannot draw half a pixel).
Call it once with HALF_W for the x axis and once with HALF_H for the y axis.worldToScreen(const p: anyp.prevX, const p: anyp.x, const alpha: numberalpha, const HALF_W: numberHALF_W);
const const sy: numbersy = function worldToScreen(prev: number, cur: number, alpha: number, halfExtent: number): numberConverts one axis of a world position into a screen pixel coordinate.
Two steps happen here:
1. Blend: prev + (cur - prev) * alpha slides the position from where it was at
the start of the last physics tick (prev) toward where it is now (cur), by
the fraction alpha (BT.renderAlpha, 0 = tick just finished, almost 1 = next
tick about to happen). This gives the true position at this exact render
moment, so motion looks smooth between ticks instead of jumping.
2. Scale and shift: dividing by FIELD_RANGE gives a fraction from -1 to +1,
multiplying by halfExtent stretches that to screen pixels, and adding
halfExtent shifts the result so world (0, 0) lands at the screen center.
Math.floor() then snaps to a whole pixel (you cannot draw half a pixel).
Call it once with HALF_W for the x axis and once with HALF_H for the y axis.worldToScreen(const p: anyp.prevY, const p: anyp.y, const alpha: numberalpha, const HALF_H: numberHALF_H);
// A 2×2 rect occupies pixels at (sx, sy), (sx+1, sy), (sx, sy+1), (sx+1, sy+1).
// We therefore need both sx and sx+1 to be inside the screen, so sx <= DISPLAY_W-2.
// DISPLAY_W - 1 = 319, so "sx >= 319" means "sx+1 would be 320" which is off-screen.
if (const sx: numbersx < 0 || const sx: numbersx >= const DISPLAY_W: 320DISPLAY_W - 1 || const sy: numbersy < 0 || const sy: numbersy >= const DISPLAY_H: 240DISPLAY_H - 1) {
continue;
}
const const slot: anyslot = const C_PARTICLE_BASE: 10C_PARTICLE_BASE + const p: anyp.hueIndex * 5 + const tier: anytier;
// Draw a 2×2 filled rectangle. Larger than 1 pixel, so young particles stand out.
// Rect2i arguments are (x, y, width, height).
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;
... 106 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(const sx: numbersx, const sy: numbersy, 2, 2), const slot: anyslot);
}
}
/**
* Draws all 12 sparks as three-layer colored squares to suggest a glowing light source.
*
* Three layers are stacked from largest (drawn first / underneath) to smallest (on top):
* Layer 1: 5×5 pixels, dim halo color - the outer glow ring.
* Layer 2: 3×3 pixels, bright body color - the vivid colored core.
* Layer 3: 1×1 pixel, white - the white-hot center point.
*
* Drawing larger shapes first and smaller shapes on top is how layered "glow" effects
* are built without any actual blending or transparency.
*/
Demo.renderSparks(): voidDraws all 12 sparks as three-layer colored squares to suggest a glowing light source.
Three layers are stacked from largest (drawn first / underneath) to smallest (on top):
Layer 1: 5×5 pixels, dim halo color - the outer glow ring.
Layer 2: 3×3 pixels, bright body color - the vivid colored core.
Layer 3: 1×1 pixel, white - the white-hot center point.
Drawing larger shapes first and smaller shapes on top is how layered "glow" effects
are built without any actual blending or transparency.renderSparks() {
// How far we are, right now, between the last completed physics tick and the
// next one (0 = just completed, just under 1 = next tick about to happen).
// Blending prevX/prevY toward x/y by this fraction gives each spark's true
// position at this render moment instead of only its last-tick position.
const const alpha: numberalpha = 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;
... 106 more ...;
spritesRefresh: () => void;
}
Main BLIT386 API namespace used by runtime demos.BT.renderAlpha: numberFractional progress between the last completed fixed update and the next.
Intended for interpolating render state between fixed-update steps.renderAlpha;
for (let let i: numberi = 0; let i: numberi < const SPARK_COUNT: 12SPARK_COUNT; let i: numberi++) {
const const spark: anyspark = this.Demo.sparks: {}sparks[let i: numberi];
// Convert the spark's blended world position to screen pixel coordinates
// (worldToScreen() above explains the blend and the conversion formula).
const const sx: numbersx = function worldToScreen(prev: number, cur: number, alpha: number, halfExtent: number): numberConverts one axis of a world position into a screen pixel coordinate.
Two steps happen here:
1. Blend: prev + (cur - prev) * alpha slides the position from where it was at
the start of the last physics tick (prev) toward where it is now (cur), by
the fraction alpha (BT.renderAlpha, 0 = tick just finished, almost 1 = next
tick about to happen). This gives the true position at this exact render
moment, so motion looks smooth between ticks instead of jumping.
2. Scale and shift: dividing by FIELD_RANGE gives a fraction from -1 to +1,
multiplying by halfExtent stretches that to screen pixels, and adding
halfExtent shifts the result so world (0, 0) lands at the screen center.
Math.floor() then snaps to a whole pixel (you cannot draw half a pixel).
Call it once with HALF_W for the x axis and once with HALF_H for the y axis.worldToScreen(const spark: anyspark.prevX, const spark: anyspark.x, const alpha: numberalpha, const HALF_W: numberHALF_W);
const const sy: numbersy = function worldToScreen(prev: number, cur: number, alpha: number, halfExtent: number): numberConverts one axis of a world position into a screen pixel coordinate.
Two steps happen here:
1. Blend: prev + (cur - prev) * alpha slides the position from where it was at
the start of the last physics tick (prev) toward where it is now (cur), by
the fraction alpha (BT.renderAlpha, 0 = tick just finished, almost 1 = next
tick about to happen). This gives the true position at this exact render
moment, so motion looks smooth between ticks instead of jumping.
2. Scale and shift: dividing by FIELD_RANGE gives a fraction from -1 to +1,
multiplying by halfExtent stretches that to screen pixels, and adding
halfExtent shifts the result so world (0, 0) lands at the screen center.
Math.floor() then snaps to a whole pixel (you cannot draw half a pixel).
Call it once with HALF_W for the x axis and once with HALF_H for the y axis.worldToScreen(const spark: anyspark.prevY, const spark: anyspark.y, const alpha: numberalpha, const HALF_H: numberHALF_H);
// The outermost layer is a 5×5 rect extending 2 pixels in each direction.
// We therefore need sx >= 2 (so sx-2 >= 0) and sx <= DISPLAY_W-3 (so sx+2 <= DISPLAY_W-1).
// The check "sx >= DISPLAY_W - 2" catches the right-edge case in one comparison.
if (const sx: numbersx < 2 || const sx: numbersx >= const DISPLAY_W: 320DISPLAY_W - 2 || const sy: numbersy < 2 || const sy: numbersy >= const DISPLAY_H: 240DISPLAY_H - 2) {
continue; // Skip sparks that are too close to the edge to draw safely.
}
// Layer 1: 5×5 dim halo, centered on (sx, sy).
// The top-left corner of a 5×5 rect centered at (sx, sy) is (sx-2, sy-2).
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;
... 106 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(const sx: numbersx - 2, const sy: numbersy - 2, 5, 5), const C_SPARK_HALO: 112C_SPARK_HALO + let i: numberi);
// Layer 2: 3×3 bright body, centered on (sx, sy).
// The top-left corner of a 3×3 rect centered at (sx, sy) is (sx-1, sy-1).
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;
... 106 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(const sx: numbersx - 1, const sy: numbersy - 1, 3, 3), const C_SPARK_BRIGHT: 100C_SPARK_BRIGHT + let i: numberi);
// Layer 3: single white-hot center pixel, exactly at (sx, sy).
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;
... 106 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(const sx: numbersx, const sy: numbersy), const C_SPARK_CORE: 6C_SPARK_CORE);
}
}
/**
* Draws two thin rows of colored squares along the very bottom of the screen.
*
* Top row - 12 spark-bright slots:
* Each of the 12 sparks gets one rectangle ~26 px wide.
* All 12 span different hues (the sparks are 30 degrees apart on the color wheel),
* so this row always looks like a full rainbow no matter where huePhase is.
*
* Bottom row - 40 particle slots (8 hues x 5 brightness tiers):
* The 40 particle palette entries are displayed left to right.
* Each group of 5 squares (40 px wide) is one hue band, going from bright to dim.
* As the global hue rotates, this entire row slides through the rainbow in real time.
*
* Think of these rows as a "legend" for the colors currently on screen.
*/
Demo.renderPaletteStrip(): voidDraws two thin rows of colored squares along the very bottom of the screen.
Top row - 12 spark-bright slots:
Each of the 12 sparks gets one rectangle ~26 px wide.
All 12 span different hues (the sparks are 30 degrees apart on the color wheel),
so this row always looks like a full rainbow no matter where huePhase is.
Bottom row - 40 particle slots (8 hues x 5 brightness tiers):
The 40 particle palette entries are displayed left to right.
Each group of 5 squares (40 px wide) is one hue band, going from bright to dim.
As the global hue rotates, this entire row slides through the rainbow in real time.
Think of these rows as a "legend" for the colors currently on screen.renderPaletteStrip() {
// Top row: 12 spark-bright color slots, one per spark
// We divide the full screen width (320 px) equally among 12 sparks.
// Math.floor() rounds down, so each rectangle is 26 px wide (320 / 12 = 26.67).
const const sparkW: anysparkW = Math.floor(const DISPLAY_W: 320DISPLAY_W / const SPARK_COUNT: 12SPARK_COUNT); // 26 px per spark rectangle.
for (let let i: numberi = 0; let i: numberi < const SPARK_COUNT: 12SPARK_COUNT; let i: numberi++) {
// Starting x position: each rectangle begins where the previous one ended.
const const x: numberx = let i: numberi * const sparkW: anysparkW;
// Width of this rectangle.
// The ternary operator "condition ? valueIfTrue : valueIfFalse" is a compact if/else.
// For the last spark (i === SPARK_COUNT - 1), we stretch to the right edge
// of the screen by using DISPLAY_W - x instead of sparkW. This fills the
// 8 leftover pixels (320 - 12*26 = 8) so the strip reaches all the way to
// the edge with no gap. For every other spark we use the standard sparkW.
const const w: anyw = let i: numberi === const SPARK_COUNT: 12SPARK_COUNT - 1 ? const DISPLAY_W: 320DISPLAY_W - const x: numberx : const sparkW: anysparkW;
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;
... 106 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(const x: numberx, const PALETTE_STRIP_SPARK_Y: numberPALETTE_STRIP_SPARK_Y, const w: anyw, const PALETTE_STRIP_SPARK_H: 3PALETTE_STRIP_SPARK_H), const C_SPARK_BRIGHT: 100C_SPARK_BRIGHT + let i: numberi);
}
// Bottom row: 40 particle color slots, each 8 px wide
// 8 hues × 5 tiers = 40 slots. 40 × 8 px = 320 px - a perfect fit.
// The slots are arranged in the same order as the palette layout:
// index 0..4 = hue 0, tiers 0..4 (brightest to darkest)
// index 5..9 = hue 1, tiers 0..4
// ...
// index 35..39 = hue 7, tiers 0..4
// So within each group of 5 squares you see one hue fading from bright to dim,
// and across all 8 groups the full rainbow is visible at a glance.
for (let let i: numberi = 0; let i: numberi < 8 * 5; let i: numberi++) {
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;
... 106 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(let i: numberi * 8, const PALETTE_STRIP_PART_Y: numberPALETTE_STRIP_PART_Y, 8, const PALETTE_STRIP_PART_H: 4PALETTE_STRIP_PART_H), const C_PARTICLE_BASE: 10C_PARTICLE_BASE + let i: numberi);
}
}
}
// Hand the Demo class to BLIT386 to start the animation 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 DemoRetro port of the classic macOS Flurry screensaver.
Twelve spark attractors trace Lissajous orbit paths; PARTICLE_COUNT particles spiral
around them via inverse-square gravity. Palette animation cycles a full rainbow every 15 seconds.Demo);