Something went wrong. Try again.
A WebGPU retro game engine for TypeScript, inspired by RetroBlit. Build pixel-perfect 2D games with a clean, fantasy-console-style API.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332// #region Types
/** * Information passed to a {@link GameLoop} dropped-frame callback. * * Emitted when the time between consecutive `requestAnimationFrame` callbacks * exceeds the auto-calibrated baseline, indicating the browser missed one or * more vsync deadlines. */export interface FrameDropEvent { /** * Estimated number of refresh intervals skipped. * * Computed as `round(deltaTime / expectedInterval) - 1`, clamped to a * minimum of 1. */ readonly droppedFrames: number;
/** Actual time between rAF callbacks, in milliseconds. */ readonly deltaTime: number;
/** * Auto-calibrated baseline interval, in milliseconds. The shortest rAF * delta observed in the rolling window, which approximates the browser's * actual vsync interval (so detection works on 60 Hz, 120 Hz, 144 Hz, * etc., regardless of the configured `targetFPS`). */ readonly expectedInterval: number;}
/** * Callback invoked once per frame whose delta exceeded the drop threshold. * * Called from inside the rAF handler on the same frame the drop is detected. * Keep the callback cheap; coalescing/rate-limiting is the caller's responsibility. * * @param event - Details of the detected drop. */export type FrameDropCallback = (event: FrameDropEvent) => void;
// #endregion
// #region GameLoop Class
/** * Fixed-timestep game loop with variable-rate rendering. * * Implements the accumulator pattern to ensure update() runs at a deterministic * rate regardless of frame timing irregularities. render() runs at the browser's * native refresh rate. * The accumulator prevents a spiral-of-death catch-up burst by capping the * number of fixed updates processed in a single frame. * * Optionally detects dropped frames by comparing each rAF delta against an * auto-calibrated baseline. The baseline is the shortest delta observed in a * rolling window of recent frames, which tracks the browser's actual vsync * interval (so detection works for any display refresh rate, including the * common Firefox case where rAF fires at the display's native rate rather * than at `targetFPS`). When the delta exceeds {@link DROP_THRESHOLD_MULTIPLIER} * times the baseline (and is shorter than {@link BACKGROUND_THRESHOLD_MS} to * filter out tab-switch pauses), the supplied callback is invoked. */export class GameLoop { // #region Constants
/** Maximum update steps per frame to prevent spiral-of-death after long pauses. */ private static readonly MAX_STEPS = 8;
/** * Frame must be at least this many times the auto-calibrated baseline to * be reported as a drop. 1.5x corresponds to a missed vsync deadline. */ private static readonly DROP_THRESHOLD_MULTIPLIER = 1.5;
/** * Gaps longer than this (in ms) are filtered out as likely tab-switch or * page-visibility pauses, which would otherwise generate huge spurious drops. */ private static readonly BACKGROUND_THRESHOLD_MS = 1000;
/** Number of recent rAF deltas retained when computing the rolling baseline. */ private static readonly BASELINE_WINDOW = 60;
/** * Minimum samples required before the baseline is trusted enough to drive * detection. Avoids false positives during page-load warm-up where a few * rAF callbacks may fire with unusual cadence. */ private static readonly BASELINE_WARMUP_SAMPLES = 8;
// #endregion
// #region State
/** Whether the loop is currently running. */ private isRunning: boolean = false;
/** Current tick count (increments once per fixed update call). */ private ticks: number = 0;
/** Timestamp of the last frame, in milliseconds. */ private lastUpdateTime: number = 0;
/** Accumulated time waiting to be consumed by fixed updates. */ private accumulator: number = 0;
/** Update interval in milliseconds (1000 / targetFPS). */ private readonly updateInterval: number;
/** Callback invoked once per fixed update step. */ private readonly onUpdate: () => void;
/** Callback invoked once per rendered frame. */ private readonly onRender: () => void;
/** Optional callback invoked when a dropped frame is detected. */ private readonly onFrameDrop: FrameDropCallback | null;
/** * Ring buffer of recent rAF deltas, in milliseconds. Pre-allocated to * {@link BASELINE_WINDOW} so writes are O(1) and no allocation occurs on * the hot path. The shortest sample approximates the browser's actual * vsync interval and is used as the baseline for drop detection. */ private readonly recentDeltas: number[] = new Array<number>(GameLoop.BASELINE_WINDOW).fill(0);
/** Next write index in {@link recentDeltas}; advances modulo BASELINE_WINDOW. */ private deltaHead: number = 0;
/** Number of valid samples in {@link recentDeltas}; saturates at BASELINE_WINDOW. */ private deltaCount: number = 0;
// #endregion
// #region Constructor
/** * Creates a new GameLoop. * * @param updateInterval - Milliseconds between fixed update steps (1000 / targetFPS). * @param onUpdate - Called once per fixed update step at the target rate. * @param onRender - Called once per rendered frame at the browser's refresh rate. * @param onFrameDrop - Optional callback invoked when a dropped frame is detected. * @throws {Error} If updateInterval is not a finite positive number. */ constructor(updateInterval: number, onUpdate: () => void, onRender: () => void, onFrameDrop?: FrameDropCallback) { if (!Number.isFinite(updateInterval) || updateInterval <= 0) { throw new Error(`GameLoop updateInterval must be a finite positive number, got: ${updateInterval}`); }
this.updateInterval = updateInterval; this.onUpdate = onUpdate; this.onRender = onRender; this.onFrameDrop = onFrameDrop ?? null; }
// #endregion
// #region Public Methods
/** * Starts the loop. * * Uses a double `requestAnimationFrame` delay before the first tick, so the * surrounding rendering surface is fully ready before timing begins. */ public start(): void { if (this.isRunning) { return; }
this.isRunning = true;
requestAnimationFrame(() => { requestAnimationFrame(() => { this.lastUpdateTime = performance.now();
requestAnimationFrame((t) => this.tick(t)); }); }); }
/** * Stops the loop. * The current frame, if already running, is allowed to finish. */ public stop(): void { this.isRunning = false; }
/** * Gets the current tick count. * Ticks increment once per fixed update step. * * @returns Number of update ticks since the loop started or since the last reset. */ public getTicks(): number { return this.ticks; }
/** * Resets the tick counter to zero. */ public resetTicks(): void { this.ticks = 0; }
// #endregion
// #region Private Loop
/** * Processes one animation frame. * * Advances the accumulator, runs zero or more fixed updates, renders once, * and schedules the next frame while the loop remains active. * * @param currentTime - High-resolution timestamp provided by rAF, in milliseconds. */ private tick(currentTime: number): void { if (!this.isRunning) { return; }
const deltaTime = currentTime - this.lastUpdateTime; this.lastUpdateTime = currentTime;
this.detectFrameDrop(deltaTime);
this.accumulator += deltaTime;
// Clamp accumulator to prevent spiral-of-death after long pauses. const maxAccumulator = this.updateInterval * GameLoop.MAX_STEPS;
if (this.accumulator > maxAccumulator) { this.accumulator = maxAccumulator; }
// Fixed update loop (capped at MAX_STEPS per frame). const steps = Math.min(Math.floor(this.accumulator / this.updateInterval), GameLoop.MAX_STEPS);
for (let i = 0; i < steps; i++) { this.onUpdate(); this.ticks++; }
this.accumulator -= steps * this.updateInterval;
// Variable render. this.onRender();
requestAnimationFrame((t) => this.tick(t)); }
/** * Reports a dropped-frame event when the rAF gap exceeds the auto-calibrated baseline. * * The baseline is the shortest rAF delta observed in a rolling window of * recent frames, which approximates the browser's actual vsync interval. * This makes detection work regardless of display refresh rate or how the * configured `targetFPS` relates to it. * * Skips reporting when: * - no callback was supplied * - the gap looks like a tab-switch / page-visibility pause (>= 1000 ms) * - the warm-up window has not yet collected enough samples * - the gap is within normal jitter of the baseline * * Background-pause gaps are also excluded from the rolling sample set so * they cannot pollute the baseline. * * @param deltaTime - Milliseconds elapsed since the previous tick. */ private detectFrameDrop(deltaTime: number): void { if (!this.onFrameDrop) { return; }
// Tab-switch / huge gap: skip detection AND skip the rolling window so // the baseline stays representative of real frames. if (deltaTime >= GameLoop.BACKGROUND_THRESHOLD_MS) { return; }
// Ring-buffer write: O(1) overwrite of the oldest slot. this.recentDeltas[this.deltaHead] = deltaTime; this.deltaHead = (this.deltaHead + 1) % GameLoop.BASELINE_WINDOW;
if (this.deltaCount < GameLoop.BASELINE_WINDOW) { this.deltaCount++; }
// Wait until enough samples have accumulated to trust the baseline. if (this.deltaCount < GameLoop.BASELINE_WARMUP_SAMPLES) { return; }
// Baseline = shortest recent delta. Robust to slow frames since drops // can only stretch deltas, never shorten them. let baseline = Number.POSITIVE_INFINITY; let seen = 0;
for (const sample of this.recentDeltas) { if (seen >= this.deltaCount) { break; }
if (sample < baseline) { baseline = sample; }
seen++; }
if (deltaTime <= baseline * GameLoop.DROP_THRESHOLD_MULTIPLIER) { return; }
const droppedFrames = Math.max(1, Math.round(deltaTime / baseline) - 1);
this.onFrameDrop({ droppedFrames, deltaTime, expectedInterval: baseline, }); }
// #endregion}
// #endregion