DOM ENGINEV0.10.0FIZLET

KERNEL V0.10.0

fizlet

Fizlet: tiny physics for your page. The kernel is the whole default export: one shared requestAnimationFrame for every engine on the page, a fixed 60 Hz step with a catch-up cap, auto-pause, bounded pools, the event ring, seeded random, settle and an fps cap for page interactions, and the ledger that gives the page back on destroy(). Every brick is its own subpath.

fizlet (kernel) · GZIP3,347 B3.3 KB of a 4.0 KB budget

Measured on this build: minified and gzipped with everything it imports, the way npm run size checks the budget in CI.

LIVE · LOADINGClick the board: the puck glides there. Half a second after it stops, the engine settles: no frames at all until the next click.
MODULES · hosted on this site
import { IDLE, READ, STEP, RENDER, … } from "https://coded-dot-design.github.io/fizlet/dist/index.js";
NPM · COMING SOON
npm install fizlet   # not published yet
import { IDLE, READ, STEP, RENDER, … } from "fizlet";
IN SHORT
const e = createEngine({ root: board, seed: 7 });
e.add({ step(e, dt) {/* sim */}, render(e) {/* DOM */} });
e.start(); // e.destroy() gives the page back

API

Every export, with its doc comment, read from the published type declarations of v0.10.0.

IDLE, READ, STEP, RENDER const

Frame phases. Read geometry only in READ, never in STEP; write the DOM only in RENDER.

export declare const IDLE = 0, READ = 1, STEP = 2, RENDER = 3;

scheduler const

Scheduler facts for tests and the debug HUD.

export declare const scheduler: {
    readonly engines: number;
    readonly requests: number;
};

createEngine function

export declare function createEngine(o?: EngineOptions): {
    signal: AbortSignal;
    events: EventRing;
    ledger: Ledger;
    random: Random;
    root: Element | null;
    /**
     * The current (or last) frame, as a typed-array view (reading a slot never boxes a number):
     * `[0]` seconds this frame advances on the shared clock (0 after a pause or a settle), `[1]` ms
     * the last finished frame's hooks took (read, steps, render). Read-only by convention.
     */
    timing: Float64Array<ArrayBuffer>;
    /** Fixed step length in seconds. */
    dt: number;
    readonly time: number;
    /** Fixed steps run so far: an integer, so reading it in a hot path never boxes a number. */
    readonly steps: number;
    readonly phase: number;
    /** Pooled things in use right now, summed over every `engine.pool` (no allocation). */
    readonly live: number;
    readonly running: boolean;
    /** Why it's paused right now (empty when running). */
    readonly paused: string[];
    add<S extends System>(s: S): S;
    /** Safe from inside a hook: the rest of the frame still runs every other system. */
    remove(s: System): void;
    /** A named, bounded pool, listed in stats(). */
    pool<T>(name: string, cap: number, create: () => T): Pool<T>;
    start(): void;
    /** Something moved this frame: keeps a settling engine awake (no-op without `settle`). */
    busy(): void;
    /** Wake a settled engine (input does this for you); the quiet time starts again. */
    wake(): void;
    stop(): void;
    /** Called by the scheduler with the rAF timestamp. */
    frame(now: number): void;
    /** Run one frame's worth of time by hand (tests, headless, a server). A hook's error throws here. */
    advance(sec: number): void;
    stats(): EngineStats;
    /** Stop for good: every listener and observer goes (one signal), the page is restored.
     *  Idempotent; afterwards the engine is inert (start, add and advance do nothing). */
    destroy(): void;
};

System interface

A system: game logic or a harness. Every hook is optional; systems run in the order added.

export interface System {
    /** Once per frame, before the steps: read cached geometry, sample input. */
    read?(e: Engine): void;
    /** Fixed step (dt = engine.dt): the simulation. No DOM reads or writes, no allocation. */
    step?(e: Engine, dt: number): void;
    /** Once per frame, after the steps: write the DOM (change-only), play sounds from the ring. */
    render?(e: Engine, alpha: number): void;
    /** When a running engine pauses (stop() or an auto-pause rule): drop held input, stop sounds. */
    pause?(e: Engine): void;
}

EngineOptions interface

export interface EngineOptions {
    /** The element the engine lives in; it pauses while this is off screen. */
    root?: Element | null;
    /** Fixed step in seconds (default 1/60). */
    step?: number;
    /** Most steps per frame before dropping time instead of spiralling (default 4). */
    maxSteps?: number;
    /** Seed for `engine.random` (default 1). */
    seed?: number;
    /** Event ring capacity per frame (default 64). */
    events?: number;
    /** Aborting this destroys the engine (Recall included). */
    signal?: AbortSignal;
    /** A hook threw during a scheduled frame. The engine is already stopped (`start()` resumes it);
     *  other engines and the shared clock keep running. Default: `console.error(error)`. */
    onError?: (error: unknown, engine: Engine) => void;
    /** Settle (page interactions): after this many seconds in which no system called `busy()`, the
     *  engine sleeps (paused: "settled", no frames at all) until `wake()`, input or `start()`. Off by default. */
    settle?: number;
    /** Frame-rate cap on the shared clock (e.g. 30 for an ambient hero). Skipped frames do no work. */
    fps?: number;
    /** Auto-pause rules (all on by default; reducedMotion is opt-in: most games calm their effects). */
    pause?: {
        hidden?: boolean;
        offscreen?: boolean;
        editable?: boolean;
        reducedMotion?: boolean;
    };
}

EngineStats interface

export interface EngineStats {
    frames: number;
    steps: number;
    dropped: number;
    time: number;
    frameMs: {
        avg: number;
        max: number;
    };
    events: {
        size: number;
        cap: number;
        dropped: number;
    };
    pools: Record<string, {
        used: number;
        cap: number;
    }>;
    ledger: number;
    paused: string[];
}

Engine type

export type Engine = ReturnType<typeof createEngine>;

Ledger class

export declare class Ledger {
    /** Replace a text node's value. False if the host changed it since our last write. */
    text(node: CharacterData, value: string): boolean;
    /** Set one inline style property (e.g. "transform", "--hp"). */
    style(el: El, prop: string, value: string, priority?: string): boolean;
    /** Set (string) or remove (null) an attribute. */
    attr(el: El, name: string, value: string | null): boolean;
    /** Add or remove one class. */
    cls(el: El, name: string, on: boolean): boolean;
    /** A node the engine put on the page (canvas, overlay, debris): removed on restore. */
    add<N extends Node>(node: N): N;
    /** Recall: put back everything we still own, newest first. Host edits win. */
    restore(): void;
    /** How many things the ledger holds (for stats). */
    get size(): number;
}

Pool class

A bounded pool. Every item is made up front, so acquiring and releasing never allocates and the pool never grows: when it's full, acquire() returns undefined (refuse new work).

export declare class Pool<T> {
    readonly cap: number;
    readonly items: T[];
    constructor(cap: number, create: () => T);
    get size(): number;
    acquire(): T | undefined;
    /** Swap-remove by index (order is not kept). Iterate backwards when releasing in a loop. */
    releaseAt(i: number): boolean;
    release(item: T): boolean;
    clear(): void;
}

EventRing class

Fixed-capacity event ring, filled during a frame's steps and read in render (sound adapter, effects, HUD). Harnesses talk through it instead of callbacks. Full = the event is dropped.

export declare class EventRing {
    readonly cap: number;
    size: number;
    dropped: number;
    constructor(cap?: number);
    emit(type: string, a?: number, b?: number, x?: number, y?: number, v?: number): boolean;
    at(i: number): FizEvent | undefined;
    clear(): void;
}

FizEvent interface

One event in the ring. Slots are reused; copy what you need before the next frame.

export interface FizEvent {
    type: string;
    a: number;
    b: number;
    x: number;
    y: number;
    v: number;
}

seeded function

export declare function seeded(seed?: number): Random;

Random interface

Seeded random (the DOM VIRUS core LCG, same sequence). Use it for anything that changes the simulation, so a run can be replayed or synced; state reads and restores it for snapshots. The state lives in a Uint32Array so drawing a number never allocates.

export interface Random {
    (): number;
    state: number;
}