Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion PROJECT_DESCRIPTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ npm run build
| Grid pathfinding | `astar`, `dijkstra`, `jumpPointSearch`, `computeFlowField`, `buildNavMesh`, `findNavMeshPath`, `manhattanDistance`, `gridFromString` | `pathfinding/astar.ts`, `pathfinding/dijkstra.ts`, `pathfinding/jumpPointSearch.ts`, `pathfinding/flowField.ts`, `pathfinding/navMesh.ts` | `examples/astar.ts`, `examples/flowField.ts`, `examples/navMesh.ts` |
| Procedural textures & terrain | `perlin`, `perlin3D`, `simplex2D`, `simplex3D`, `worley`, `worleySample`, `waveFunctionCollapse`, `cellularAutomataCave`, `poissonDiskSampling`, `computeVoronoiDiagram`, `diamondSquare`, `generateLSystem`, `generateBspDungeon`, `generateRecursiveMaze`, `generatePrimMaze`, `generateKruskalMaze`, `generateWilsonMaze`, `generateAldousBroderMaze`, `generateRecursiveDivisionMaze` | `procedural/*.ts` | `examples/simplex.ts`, `examples/worley.ts`, `examples/waveFunctionCollapse.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts`, `examples/dungeonBsp.ts`, `examples/mazeRecursive.ts`, `examples/mazePrim.ts`, `examples/mazeKruskal.ts`, `examples/mazeWilson.ts`, `examples/mazeAldous.ts`, `examples/mazeDivision.ts` |
| Spatial queries & collision | `Quadtree`, `aabbCollision`, `aabbIntersection`, `satCollision`, `circleRayIntersection`, `sweptAABB` | `spatial/*.ts` | `examples/sat.ts` |
| Web performance & UI throttling | `debounce`, `throttle`, `LRUCache`, `memoize`, `deduplicateRequest`, `clearRequestDedup`, `calculateVirtualRange`, `createWeightedAliasSampler`, `createObjectPool`, `fisherYatesShuffle` | `util/*.ts` | `examples/requestDedup.ts`, `examples/virtualScroll.ts`, `examples/weightedAlias.ts`, `examples/objectPool.ts`, `examples/fisherYates.ts` |
| Web performance & UI throttling | `debounce`, `throttle`, `LRUCache`, `memoize`, `deduplicateRequest`, `clearRequestDedup`, `calculateVirtualRange`, `createWeightedAliasSampler`, `createObjectPool`, `fisherYatesShuffle`, `createFixedTimestepLoop` | `util/*.ts` | `examples/requestDedup.ts`, `examples/virtualScroll.ts`, `examples/weightedAlias.ts`, `examples/objectPool.ts`, `examples/fisherYates.ts`, `examples/fixedTimestep.ts` |
| Text & search | `fuzzySearch`, `fuzzyScore`, `Trie`, `binarySearch`, `levenshteinDistance` | `search/*.ts` | `examples/search.ts` |
| Data transforms & diffing | `diff`, `deepClone`, `groupBy`, `diffJson`, `applyJsonDiff` | `data/*.ts` | `examples/jsonDiff.ts` |
| Graph traversal | `graphBFS`, `graphDFS`, `topologicalSort` | `graph/traversal.ts` | `examples/graph.ts` |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ CDN usage:
| Procedural generation | `perlin`, `perlin3D`, `simplex2D`, `simplex3D`, `worley`, `worleySample`, `waveFunctionCollapse`, `cellularAutomataCave`, `poissonDiskSampling`, `computeVoronoiDiagram`, `diamondSquare`, `generateLSystem`, `generateBspDungeon`, `generateRecursiveMaze`, `generatePrimMaze`, `generateKruskalMaze`, `generateWilsonMaze`, `generateAldousBroderMaze`, `generateRecursiveDivisionMaze` | `procedural/*.ts` | `examples/simplex.ts`, `examples/worley.ts`, `examples/waveFunctionCollapse.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts`, `examples/dungeonBsp.ts`, `examples/mazeRecursive.ts`, `examples/mazePrim.ts`, `examples/mazeKruskal.ts`, `examples/mazeWilson.ts`, `examples/mazeAldous.ts`, `examples/mazeDivision.ts` |
| Spatial queries & collision | `Quadtree`, `aabbCollision`, `aabbIntersection`, `satCollision`, `circleRayIntersection`, `sweptAABB` | `spatial/*.ts` | `examples/sat.ts` |
| AI behaviours & crowds | `seek`, `flee`, `arrive`, `pursue`, `wander`, `updateBoids`, `BehaviorTree`, `rvoStep` | `ai/steering.ts`, `ai/boids.ts`, `ai/behaviorTree.ts`, `ai/rvo.ts` | `examples/steering.ts`, `examples/boids.ts`, `examples/rvo.ts` |
| Web performance & UI | `debounce`, `throttle`, `LRUCache`, `memoize`, `deduplicateRequest`, `clearRequestDedup`, `calculateVirtualRange`, `createWeightedAliasSampler`, `createObjectPool`, `fisherYatesShuffle` | `util/*.ts` | `examples/requestDedup.ts`, `examples/virtualScroll.ts`, `examples/weightedAlias.ts`, `examples/objectPool.ts`, `examples/fisherYates.ts` |
| Web performance & UI | `debounce`, `throttle`, `LRUCache`, `memoize`, `deduplicateRequest`, `clearRequestDedup`, `calculateVirtualRange`, `createWeightedAliasSampler`, `createObjectPool`, `fisherYatesShuffle`, `createFixedTimestepLoop` | `util/*.ts` | `examples/requestDedup.ts`, `examples/virtualScroll.ts`, `examples/weightedAlias.ts`, `examples/objectPool.ts`, `examples/fisherYates.ts`, `examples/fixedTimestep.ts` |
| Search & text | `fuzzySearch`, `fuzzyScore`, `Trie`, `binarySearch`, `levenshteinDistance` | `search/*.ts` | `examples/search.ts` |
| Data & diff pipelines | `diff`, `deepClone`, `groupBy`, `diffJson`, `applyJsonDiff` | `data/*.ts` | `examples/jsonDiff.ts` |
| Graph algorithms | `graphBFS`, `graphDFS`, `topologicalSort` | `graph/traversal.ts` | `examples/graph.ts` |
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
- [x] Dungeon generation suite (BSP subdivision, rooms & corridors variants)
- [ ] Maze algorithms pack (Recursive backtracking ✅, Prim's ✅, Kruskal's ✅, Wilson's ✅, Aldous–Broder ✅, Recursive Division ✅)
- Gameplay systems & utilities:
- [ ] Fixed-timestep game loop utility with interpolation helpers
- [x] Fixed-timestep game loop utility with interpolation helpers
- [ ] Delta-time manager for frame-independent timing
- [x] Object pool helper for reusable entities
- [x] Weighted random selector (alias method)
Expand Down
31 changes: 31 additions & 0 deletions docs/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export const examples: {
readonly createWeightedAliasSampler: 'examples/weightedAlias.ts';
readonly createObjectPool: 'examples/objectPool.ts';
readonly fisherYatesShuffle: 'examples/fisherYates.ts';
readonly createFixedTimestepLoop: 'examples/fixedTimestep.ts';
};
readonly ai: {
readonly seek: 'examples/steering.ts';
Expand Down Expand Up @@ -859,6 +860,36 @@ export function createObjectPool<T>(options: ObjectPoolOptions<T>): ObjectPool<T
*/
export function fisherYatesShuffle<T>(items: T[], options?: { random?: () => number }): T[];

/**
* Fixed timestep options for deterministic update loops.
* Use for: game loops, physics updates, consistent simulations.
* Import: util/fixedTimestep.ts
*/
export interface FixedTimestepOptions {
step: number;
maxDelta?: number;
update: (context: { alpha: number; accumulator: number; elapsed: number }) => void;
render?: (context: { alpha: number; accumulator: number; elapsed: number }) => void;
}

/**
* Fixed timestep loop interface.
* Import: util/fixedTimestep.ts
*/
export interface FixedTimestepLoop {
start(): void;
stop(): void;
isRunning(): boolean;
}

/**
* Creates a fixed timestep loop for deterministic updates.
* Use for: gameplay loops, physics, consistent tick simulation.
* Performance: O(n) updates per frame capped by maxDelta
* Import: util/fixedTimestep.ts
*/
export function createFixedTimestepLoop(options: FixedTimestepOptions): FixedTimestepLoop;

/**
* Least recently used cache.
* Use for: memoizing responses, data loaders, pagination caches.
Expand Down
15 changes: 15 additions & 0 deletions examples/fixedTimestep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { createFixedTimestepLoop } from '../src/index.js';

let ticks = 0;
const loop = createFixedTimestepLoop({
step: 1 / 60,
update: () => {
ticks += 1;
if (ticks >= 3) {
loop.stop();
console.log('Stopped after 3 ticks');
}
},
});

loop.start();
8 changes: 8 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export const examples = {
createWeightedAliasSampler: 'examples/weightedAlias.ts',
createObjectPool: 'examples/objectPool.ts',
fisherYatesShuffle: 'examples/fisherYates.ts',
createFixedTimestepLoop: 'examples/fixedTimestep.ts',
},
ai: {
seek: 'examples/steering.ts',
Expand Down Expand Up @@ -431,6 +432,13 @@ export { createObjectPool } from './util/objectPool.js';
*/
export { fisherYatesShuffle } from './util/fisherYates.js';

/**
* Fixed timestep loop for deterministic gameplay updates.
*
* Example file: examples/fixedTimestep.ts
*/
export { createFixedTimestepLoop } from './util/fixedTimestep.js';

/**
* Virtual scroll type exports to help define rendering contracts.
*/
Expand Down
99 changes: 99 additions & 0 deletions src/util/fixedTimestep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
export interface FixedTimestepOptions {
/** Target fixed update step in seconds. */
step: number;
/** Maximum delta to avoid spiral of death (seconds). */
maxDelta?: number;
/** Callback invoked for each fixed update tick. */
update: (context: { alpha: number; accumulator: number; elapsed: number }) => void;
/** Callback invoked for rendering/interpolation with interpolation alpha. */
render?: (context: { alpha: number; accumulator: number; elapsed: number }) => void;
}

export interface FixedTimestepLoop {
start(): void;
stop(): void;
isRunning(): boolean;
}

/**
* Creates a fixed timestep loop ideal for gameplay update ticks.
* Useful for: deterministic game logic and interpolation between frames.
*/
export function createFixedTimestepLoop({
step,
maxDelta = step * 5,
update,
render,
}: FixedTimestepOptions): FixedTimestepLoop {
if (step <= 0) {
throw new Error('step must be greater than zero.');
}
if (typeof update !== 'function') {
throw new Error('update callback is required.');
}

let running = false;
let lastTime: number | undefined;
let accumulator = 0;
let frameId: number | undefined;

const epsilon = step * 0.0001;

function loop(timestamp: number) {
if (!running) {
return;
}
if (lastTime === undefined) {
lastTime = timestamp;
}

let delta = (timestamp - lastTime) / 1000;
lastTime = timestamp;
if (delta > maxDelta) {
delta = maxDelta;
}

accumulator += delta;

while (accumulator + epsilon >= step) {
accumulator -= step;
update({ alpha: accumulator / step, accumulator, elapsed: step });
}
if (accumulator < 0) {
accumulator = 0;
}

if (render) {
render({ alpha: accumulator / step, accumulator, elapsed: delta });
}

frameId = requestAnimationFrame(loop);
}

function start() {
if (running) {
return;
}
running = true;
lastTime = undefined;
accumulator = 0;
frameId = requestAnimationFrame(loop);
}

function stop() {
if (!running) {
return;
}
running = false;
if (frameId !== undefined) {
cancelAnimationFrame(frameId);
frameId = undefined;
}
}

function isRunning(): boolean {
return running;
}

return { start, stop, isRunning };
}
62 changes: 62 additions & 0 deletions tests/fixedTimestep.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

import { createFixedTimestepLoop } from '../src/index.js';

const originalRAF = globalThis.requestAnimationFrame;
const originalCancel = globalThis.cancelAnimationFrame;

afterEach(() => {
globalThis.requestAnimationFrame = originalRAF;
globalThis.cancelAnimationFrame = originalCancel;
});

describe('createFixedTimestepLoop', () => {
it('invokes update multiple times based on accumulated delta', () => {
let storedCallback: FrameRequestCallback | undefined;
globalThis.requestAnimationFrame = vi.fn((cb: FrameRequestCallback) => {
storedCallback = cb;
return 1;
});
globalThis.cancelAnimationFrame = vi.fn();

let updates = 0;
const loop = createFixedTimestepLoop({
step: 0.1,
update: () => {
updates += 1;
},
});

loop.start();
expect(storedCallback).toBeDefined();
storedCallback?.(0);
storedCallback?.(300); // 0.3 seconds -> 3 updates
expect(updates).toBe(3);
loop.stop();
});

it('caps delta by maxDelta to avoid spiral', () => {
const callbacks: FrameRequestCallback[] = [];
globalThis.requestAnimationFrame = vi.fn((cb: FrameRequestCallback) => {
callbacks.push(cb);
return callbacks.length;
});
globalThis.cancelAnimationFrame = vi.fn();

let totalElapsed = 0;
const loop = createFixedTimestepLoop({
step: 0.1,
maxDelta: 0.3,
update: ({ elapsed }) => {
totalElapsed += elapsed;
},
});

loop.start();
expect(callbacks.length).toBeGreaterThan(0);
callbacks[0]?.(0);
callbacks[1]?.(1000);
expect(totalElapsed).toBeCloseTo(0.3, 5);
loop.stop();
});
});
2 changes: 2 additions & 0 deletions tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ describe('package entry point', () => {
expect(examples.performance.createWeightedAliasSampler).toBe('examples/weightedAlias.ts');
expect(examples.performance.createObjectPool).toBe('examples/objectPool.ts');
expect(examples.performance.fisherYatesShuffle).toBe('examples/fisherYates.ts');
expect(examples.performance.createFixedTimestepLoop).toBe('examples/fixedTimestep.ts');
expect(examples.performance.createWeightedAliasSampler).toBe('examples/weightedAlias.ts');
expect(examples.search.Trie).toBe('examples/search.ts');
expect(examples.pathfinding.buildNavMesh).toBe('examples/navMesh.ts');
Expand Down Expand Up @@ -87,6 +88,7 @@ describe('package entry point', () => {
| 'createWeightedAliasSampler'
| 'createObjectPool'
| 'fisherYatesShuffle'
| 'createFixedTimestepLoop'
>();
});
});