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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461/** * Unit tests for {@link GameLoop}. * * Verifies the fixed-timestep loop contract: * - constructor validation for invalid update intervals * - public tick-counter and stop behavior * - start-up scheduling through `requestAnimationFrame` * - internal tick processing, including multiple updates per frame * - accumulator clamping to avoid runaway catch-up work * - optional dropped-frame detection callback * * Private frame-advance behavior is exercised through a narrow type cast so * the suite can validate timing semantics without changing production APIs. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { FrameDropEvent } from './GameLoop';import { GameLoop } from './GameLoop';
describe('GameLoop', () => { // #region Constructor
describe('constructor', () => { it('should accept a valid positive update interval', () => { const loop = new GameLoop(16.67, vi.fn(), vi.fn());
expect(loop).toBeDefined(); });
it('should throw on a zero interval', () => { expect(() => new GameLoop(0, vi.fn(), vi.fn())).toThrow('finite positive number'); });
it('should throw on a negative interval', () => { expect(() => new GameLoop(-16, vi.fn(), vi.fn())).toThrow('finite positive number'); });
it('should throw on NaN interval', () => { expect(() => new GameLoop(NaN, vi.fn(), vi.fn())).toThrow('finite positive number'); });
it('should throw on an Infinity interval', () => { expect(() => new GameLoop(Infinity, vi.fn(), vi.fn())).toThrow('finite positive number'); });
it('should throw on a negative Infinity interval', () => { expect(() => new GameLoop(-Infinity, vi.fn(), vi.fn())).toThrow('finite positive number'); }); });
// #endregion
// #region Public Methods
describe('getTicks', () => { it('should return 0 initially', () => { const loop = new GameLoop(16.67, vi.fn(), vi.fn());
expect(loop.getTicks()).toBe(0); }); });
describe('resetTicks', () => { it('should reset tick count to 0', () => { const loop = new GameLoop(16.67, vi.fn(), vi.fn());
(loop as unknown as { ticks: number }).ticks = 5;
loop.resetTicks();
expect(loop.getTicks()).toBe(0); }); });
describe('stop', () => { it('should not throw when called before start', () => { const loop = new GameLoop(16.67, vi.fn(), vi.fn());
expect(() => loop.stop()).not.toThrow(); }); });
// #endregion
// #region start
describe('start', () => { beforeEach(() => { vi.stubGlobal('requestAnimationFrame', vi.fn()); });
afterEach(() => { vi.unstubAllGlobals(); });
it('should call requestAnimationFrame when started', () => { const loop = new GameLoop(16.67, vi.fn(), vi.fn());
loop.start();
expect(requestAnimationFrame).toHaveBeenCalledOnce(); });
it('should not call requestAnimationFrame again if already running', () => { const loop = new GameLoop(16.67, vi.fn(), vi.fn());
loop.start();
const callsBefore = (requestAnimationFrame as ReturnType<typeof vi.fn>).mock.calls.length;
loop.start();
expect((requestAnimationFrame as ReturnType<typeof vi.fn>).mock.calls.length).toBe(callsBefore); }); });
// #endregion
// #region start (RAF flush)
describe('start (RAF flush)', () => { afterEach(() => { vi.unstubAllGlobals(); });
it('sets lastUpdateTime after flushing nested RAF callbacks', () => { const rafCallbacks: Array<(time?: number) => void> = [];
vi.stubGlobal( 'requestAnimationFrame', vi.fn((cb: (time?: number) => void) => { rafCallbacks.push(cb);
return rafCallbacks.length; }), );
const loop = new GameLoop(16.67, vi.fn(), vi.fn()); const p = loop as unknown as { lastUpdateTime: number };
loop.start();
// Execute outer RAF callback. rafCallbacks[0]?.();
// Execute inner RAF callback - sets lastUpdateTime and schedules first tick. rafCallbacks[1]?.();
expect(p.lastUpdateTime).toBeGreaterThan(0); expect(rafCallbacks).toHaveLength(3); }); });
// #endregion
// #region tick (private)
describe('tick (via type cast)', () => { type PrivateLoop = { tick: (currentTime: number) => void; isRunning: boolean; lastUpdateTime: number; accumulator: number; };
beforeEach(() => { vi.stubGlobal('requestAnimationFrame', vi.fn()); });
afterEach(() => { vi.unstubAllGlobals(); });
it('should do nothing when isRunning is false', () => { const onUpdate = vi.fn(); const onRender = vi.fn(); const loop = new GameLoop(16.67, onUpdate, onRender);
(loop as unknown as PrivateLoop).tick(100);
expect(onUpdate).not.toHaveBeenCalled(); expect(onRender).not.toHaveBeenCalled(); });
it('should call onUpdate and onRender when running', () => { const onUpdate = vi.fn(); const onRender = vi.fn(); const loop = new GameLoop(10, onUpdate, onRender); const p = loop as unknown as PrivateLoop;
p.isRunning = true; p.lastUpdateTime = 0; p.tick(20); // 20ms delta at 10ms interval = 2 update steps
expect(onUpdate).toHaveBeenCalledTimes(2); expect(onRender).toHaveBeenCalledOnce(); });
it('should clamp accumulator to prevent spiral-of-death', () => { const onUpdate = vi.fn(); const loop = new GameLoop(10, onUpdate, vi.fn()); const p = loop as unknown as PrivateLoop;
p.isRunning = true; p.lastUpdateTime = 0; p.tick(10000); // huge pause - MAX_STEPS = 8 caps at 8 updates
expect(onUpdate).toHaveBeenCalledTimes(8); });
it('should call onRender exactly once per frame regardless of update step count', () => { const onRender = vi.fn(); const loop = new GameLoop(10, vi.fn(), onRender); const p = loop as unknown as PrivateLoop;
p.isRunning = true; p.lastUpdateTime = 0; p.tick(10000); // many steps, but one render
expect(onRender).toHaveBeenCalledOnce(); });
it('should increment tick count by the number of update steps', () => { const loop = new GameLoop(10, vi.fn(), vi.fn()); const p = loop as unknown as PrivateLoop;
p.isRunning = true; p.lastUpdateTime = 0; p.tick(30); // 30ms at 10ms interval = 3 steps
expect(loop.getTicks()).toBe(3); });
it('should schedule the next frame via requestAnimationFrame', () => { const loop = new GameLoop(10, vi.fn(), vi.fn()); const p = loop as unknown as PrivateLoop;
p.isRunning = true; p.lastUpdateTime = 0; p.tick(10);
expect(requestAnimationFrame).toHaveBeenCalled(); }); });
// #endregion
// #region Frame-drop detection
describe('frame-drop detection', () => { type PrivateLoop = { tick: (currentTime: number) => void; isRunning: boolean; lastUpdateTime: number; accumulator: number; recentDeltas: number[]; deltaHead: number; deltaCount: number; };
/** * Pre-populates the ring-buffer baseline so that the next tick is * evaluated against an established baseline rather than skipped during * warm-up. Caller is responsible for passing a `count` no greater than * the configured `BASELINE_WINDOW` (60); all current callers use 16. */ const primeBaseline = (loop: GameLoop, sampleMs: number, count: number = 16): void => { const p = loop as unknown as PrivateLoop;
for (let i = 0; i < count; i++) { // eslint-disable-next-line security/detect-object-injection -- test helper, index is a bounded loop counter p.recentDeltas[i] = sampleMs; }
p.deltaHead = count; p.deltaCount = count; };
beforeEach(() => { vi.stubGlobal('requestAnimationFrame', vi.fn()); });
afterEach(() => { vi.unstubAllGlobals(); });
it('should not invoke the callback when delta is within the baseline', () => { const onFrameDrop = vi.fn(); const loop = new GameLoop(16.67, vi.fn(), vi.fn(), onFrameDrop); const p = loop as unknown as PrivateLoop;
primeBaseline(loop, 16.67);
p.isRunning = true; p.lastUpdateTime = 0; p.tick(16.67); // exactly one baseline interval
expect(onFrameDrop).not.toHaveBeenCalled(); });
it('should not invoke the callback when delta is just under the 1.5x threshold', () => { const onFrameDrop = vi.fn(); const loop = new GameLoop(16.67, vi.fn(), vi.fn(), onFrameDrop); const p = loop as unknown as PrivateLoop;
primeBaseline(loop, 16.67);
p.isRunning = true; p.lastUpdateTime = 0; p.tick(16.67 * 1.49);
expect(onFrameDrop).not.toHaveBeenCalled(); });
it('should invoke the callback when delta exceeds 1.5x the baseline', () => { const onFrameDrop = vi.fn(); const loop = new GameLoop(16.67, vi.fn(), vi.fn(), onFrameDrop); const p = loop as unknown as PrivateLoop;
primeBaseline(loop, 16.67);
p.isRunning = true; p.lastUpdateTime = 0; p.tick(50); // ~3 baseline intervals
expect(onFrameDrop).toHaveBeenCalledOnce();
const event = onFrameDrop.mock.calls[0]?.[0] as FrameDropEvent;
expect(event.droppedFrames).toBe(2); // round(50 / 16.67) - 1 = 2 expect(event.deltaTime).toBe(50); expect(event.expectedInterval).toBeCloseTo(16.67); });
it('should report at least one dropped frame even when the gap is just past the threshold', () => { const onFrameDrop = vi.fn(); const loop = new GameLoop(10, vi.fn(), vi.fn(), onFrameDrop); const p = loop as unknown as PrivateLoop;
primeBaseline(loop, 10);
p.isRunning = true; p.lastUpdateTime = 0; p.tick(16); // 1.6x baseline - between 1 and 2 in raw frame terms
expect(onFrameDrop).toHaveBeenCalledOnce();
const event = onFrameDrop.mock.calls[0]?.[0] as FrameDropEvent;
expect(event.droppedFrames).toBeGreaterThanOrEqual(1); });
it('should auto-calibrate the baseline to the actual rAF cadence', () => { // Configured for 60 FPS but the browser fires rAF at 120 Hz // (common on a 120 Hz display in Firefox / Chrome with vsync at // the native rate). A missed vsync (~16.67 ms) at 120 Hz would // sit far below 1.5x of the configured 16.67 ms updateInterval // and would not be reported in a naive implementation. const onFrameDrop = vi.fn(); const loop = new GameLoop(16.67, vi.fn(), vi.fn(), onFrameDrop); const p = loop as unknown as PrivateLoop;
primeBaseline(loop, 8.33); // 120 Hz cadence
p.isRunning = true; p.lastUpdateTime = 0; p.tick(16.67); // one missed vsync at 120 Hz
expect(onFrameDrop).toHaveBeenCalledOnce();
const event = onFrameDrop.mock.calls[0]?.[0] as FrameDropEvent;
expect(event.expectedInterval).toBeCloseTo(8.33); expect(event.droppedFrames).toBe(1); });
it('should detect drops on a 144 Hz display even with a low targetFPS', () => { // Worst-case mismatch: updateInterval = 33.33 ms (targetFPS = 30) // but rAF fires at 144 Hz (~6.94 ms). A single missed vsync of // ~13.88 ms is less than half the configured updateInterval but // is still a real visible stutter, and must be reported. const onFrameDrop = vi.fn(); const loop = new GameLoop(33.33, vi.fn(), vi.fn(), onFrameDrop); const p = loop as unknown as PrivateLoop;
primeBaseline(loop, 6.94); // 144 Hz cadence
p.isRunning = true; p.lastUpdateTime = 0; p.tick(13.88); // one missed vsync at 144 Hz
expect(onFrameDrop).toHaveBeenCalledOnce();
const event = onFrameDrop.mock.calls[0]?.[0] as FrameDropEvent;
expect(event.expectedInterval).toBeCloseTo(6.94); expect(event.droppedFrames).toBe(1); expect(event.deltaTime).toBe(13.88); });
it('should skip detection during the warm-up window', () => { const onFrameDrop = vi.fn(); const loop = new GameLoop(16.67, vi.fn(), vi.fn(), onFrameDrop); const p = loop as unknown as PrivateLoop;
// No priming: rolling window is empty. Even a huge gap should not // fire because the baseline has not been established. p.isRunning = true; p.lastUpdateTime = 0; p.tick(100);
expect(onFrameDrop).not.toHaveBeenCalled(); });
it('should suppress the callback when the gap looks like a backgrounded tab', () => { const onFrameDrop = vi.fn(); const loop = new GameLoop(16.67, vi.fn(), vi.fn(), onFrameDrop); const p = loop as unknown as PrivateLoop;
primeBaseline(loop, 16.67);
p.isRunning = true; p.lastUpdateTime = 0; p.tick(5000); // 5 seconds, clearly a tab switch or pause
expect(onFrameDrop).not.toHaveBeenCalled(); });
it('should not pollute the baseline with backgrounded gaps', () => { const onFrameDrop = vi.fn(); const loop = new GameLoop(16.67, vi.fn(), vi.fn(), onFrameDrop); const p = loop as unknown as PrivateLoop;
primeBaseline(loop, 16.67);
const countBefore = p.deltaCount; const headBefore = p.deltaHead;
p.isRunning = true; p.lastUpdateTime = 0; p.tick(5000); // backgrounded gap
expect(p.deltaCount).toBe(countBefore); expect(p.deltaHead).toBe(headBefore); });
it('should be a no-op when no callback is provided', () => { const loop = new GameLoop(16.67, vi.fn(), vi.fn()); const p = loop as unknown as PrivateLoop;
p.isRunning = true; p.lastUpdateTime = 0;
expect(() => p.tick(100)).not.toThrow(); }); });
// #endregion});