Skip to content
86 changes: 86 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,88 @@
# core
A collection of reusable core features, classes and functionalities used to create web-based apps and games.

# SchedulerService

A game-loop-driven timer scheduler that respects pause state. Provides `setTimeout` equivalents driven by `deltaTime` instead of the browser clock — so timers pause when your game pauses.

---

## Usage

### 1. Wire the game loop

Call `update()` every frame. Skip it when paused — timers will naturally pause too.

```typescript
import schedulerService from '@curiouslearning/core';

draw(deltaTime: number) {
if (!this.isPaused) {
schedulerService.update(deltaTime);
}
}
```

> **Note:** If used outside of `requestAnimationFrame` — for example in a custom loop that may produce high or unpredictable delta values — it is recommended to cap the delta time to prevent timers from misfiring:
> ```typescript
> const MAX_DELTA_MS = 100;
> schedulerService.update(Math.min(deltaTime, MAX_DELTA_MS));
> ```


---

### 2. Create a registry per component

Call `createRegistry()` to get a `TimeoutRegistry` scoped to your component. Schedule timers through the registry and call `cancelAll()` on teardown.

```typescript
import schedulerService from '@curiouslearning/core';
import type { TimeoutRegistry, TimerId } from '@curiouslearning/core';

class TutorialHandler {
private registry: TimeoutRegistry = schedulerService.createRegistry();
private timerId: TimerId | null = null;

startDelay() {
this.timerId = this.registry.setTimeout(() => {
this.onDelayComplete();
}, 6000);
}

reset() {
this.registry.cancel(this.timerId);
this.timerId = null;
}

destroy() {
this.registry.cancelAll();
}
}
```

---

## API

### `schedulerService`

The singleton instance. Import and use directly.

| Method | Description |
|---|---|
| `update(delta: number)` | Ticks all active timers. Must be called every frame from the game loop. |
| `createRegistry()` | Returns a new `TimeoutRegistry` instance scoped to a component or entity. |
| `destroy()` | Clears all active timers. Call on full game teardown. |

---

### `TimeoutRegistry`

Returned by `schedulerService.createRegistry()`. Manages timers scoped to a single component or entity.

| Method | Description |
|---|---|
| `setTimeout(callback, delay)` | Schedules a one-time callback after `delay` ms. Returns a `TimerId`. Auto-removes itself from the registry after firing. |
| `cancel(id)` | Cancels a specific timer by ID. Safely accepts `null` or `undefined`. |
| `cancelAll()` | Cancels all timers owned by this registry. Call on component teardown. |
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ export * from './canvas-renderer/canvas-renderer';
export * from './delta-time/delta-time';
export * from './scene-manager/scene-manager';
export * from './android-interface/android-interface';
export * from './pub-sub/pub-sub';
export * from './pub-sub/pub-sub';
export { default as schedulerService } from './scheduler'
41 changes: 41 additions & 0 deletions src/scheduler/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import Scheduler from './scheduler';
import { TimeoutRegistry } from './timeout-registry';
import type { TimerId } from './scheduler';

class SchedulerService {
// Making scheduler a singleton.
private scheduler = new Scheduler();

/**
* Drives all active timers. Must be called every frame from the game loop.
* @param delta Time elapsed since last frame in milliseconds.
*/
update(delta: number): void {
this.scheduler.update(delta);
}

/**
* Creates a new TimeoutRegistry instance scoped to a component or entity.
* Use the returned registry to schedule and manage timers tied to that scope.
* Call registry.cancelAll() on component teardown.
* @returns A new TimeoutRegistry instance.
*/
createRegistry(): TimeoutRegistry {
return new TimeoutRegistry(this.scheduler);
}

/**
* Clears all active timers across all registries.
* Call on full game teardown.
*/
destroy(): void {
this.scheduler.destroy();
}
}

export type { TimerId };
export type { TimeoutRegistry };

// Making scheduler service a singleton.
const schedulerService = new SchedulerService();
export default schedulerService;
55 changes: 55 additions & 0 deletions src/scheduler/scheduler.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import Scheduler from './scheduler';

describe('Scheduler', () => {
let scheduler: Scheduler;

beforeEach(() => {
scheduler = new Scheduler();
});

it('should fire a setTimeout callback after the specified delay', () => {
const callback = jest.fn();
scheduler.setTimeout(callback, 1000);

scheduler.update(999);
expect(callback).not.toHaveBeenCalled();

scheduler.update(1);
expect(callback).toHaveBeenCalledTimes(1);
});

it('should not fire a setTimeout callback after it has been cancelled', () => {
const callback = jest.fn();
const id = scheduler.setTimeout(callback, 1000);

scheduler.cancel(id);
scheduler.update(1000);

expect(callback).not.toHaveBeenCalled();
});

it('should fire a setInterval callback repeatedly', () => {
const callback = jest.fn();
scheduler.setInterval(callback, 500);

scheduler.update(500);
expect(callback).toHaveBeenCalledTimes(1);

scheduler.update(500);
expect(callback).toHaveBeenCalledTimes(2);

scheduler.update(500);
expect(callback).toHaveBeenCalledTimes(3);
});

it('should clear all timers on destroy', () => {
const callback = jest.fn();
scheduler.setTimeout(callback, 500);
scheduler.setInterval(callback, 200);

scheduler.destroy();
scheduler.update(1000);

expect(callback).not.toHaveBeenCalled();
});
});
103 changes: 103 additions & 0 deletions src/scheduler/scheduler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { Opaque } from 'type-fest';

type TimerId = Opaque<number, 'TimerId'>;
type Timer = {
id: TimerId;
callback: () => void;
delay: number;
remaining: number;
loop: boolean;
};

/**
* Custom scheduler for managing time-based events within the game loop.
* Unlike standard window.setTimeout/setInterval, this respects the game's
* pause state and is driven by deltaTime from the main update loop.
*/
export default class Scheduler {
private nextTimerId = 0;
private timers: Map<TimerId, Timer> = new Map();

/**
* Schedules a one-time callback after a specified delay.
* @param callback The function to execute.
* @param delay Delay in milliseconds.
* @returns A unique TimerId for clearing the timeout.
*/
setTimeout(callback: () => void, delay: number): TimerId {
const id = this.nextTimerId++ as TimerId;
this.timers.set(id, {
id,
callback,
delay,
remaining: delay,
loop: false,
});
return id;
}

/**
* Cancels a previously scheduled timeout or interval.
* @param id The ID of the timer to clear.
*/
cancel(id: TimerId): void {
if (id !== undefined && id !== null) {
this.timers.delete(id);
}
}

/**
* Schedules a repeating callback at a specified interval.
* @param callback The function to execute.
* @param delay Interval in milliseconds.
* @returns A unique TimerId for clearing the interval.
*/
setInterval(callback: () => void, delay: number): TimerId {
const id = this.nextTimerId++ as TimerId;
const safeDelay = Math.max(1, delay); // Minimum 1ms to prevent runaway intervals
this.timers.set(id, {
id,
callback,
delay: safeDelay,
remaining: safeDelay,
loop: true,
});
return id;
}

/**
* Updates all active timers by subtracting the provided delta time.
* Executed by the main game loop.
* @param delta The time elapsed since the last update in milliseconds.
*/
update(delta: number): void {
const snapshot = [...this.timers.values()];
for (const timer of snapshot) {
if (!this.timers.has(timer.id)) continue; // already cancelled
timer.remaining -= delta;
if (timer.remaining <= 0) {
try {
timer.callback();
} catch (e) {
console.error("Error in scheduled callback:", e);
}

if (timer.loop) {
timer.remaining += timer.delay;
} else {
this.cancel(timer.id);
}
}
}
}

/**
* Clears all active timers and resets the scheduler state.
*/
destroy(): void {
this.timers.clear();
}
}


export { TimerId };
57 changes: 57 additions & 0 deletions src/scheduler/timeout-registry.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import Scheduler from './scheduler';
import { TimeoutRegistry } from './timeout-registry';

describe('TimeoutRegistry', () => {
let scheduler: Scheduler;
let registry: TimeoutRegistry;

beforeEach(() => {
scheduler = new Scheduler();
registry = new TimeoutRegistry(scheduler);
});

it('should fire a setTimeout callback after the specified delay', () => {
const callback = jest.fn();
registry.setTimeout(callback, 1000);

scheduler.update(999);
expect(callback).not.toHaveBeenCalled();

scheduler.update(1);
expect(callback).toHaveBeenCalledTimes(1);
});

it('should not fire a callback after it has been cancelled', () => {
const callback = jest.fn();
const id = registry.setTimeout(callback, 1000);

registry.cancel(id);
scheduler.update(1000);

expect(callback).not.toHaveBeenCalled();
});

it('should cancel all timers on cancelAll', () => {
const callbackA = jest.fn();
const callbackB = jest.fn();
registry.setTimeout(callbackA, 500);
registry.setTimeout(callbackB, 800);

registry.cancelAll();
scheduler.update(1000);

expect(callbackA).not.toHaveBeenCalled();
expect(callbackB).not.toHaveBeenCalled();
});

it('should not throw when cancelling an already-fired timer', () => {
const callback = jest.fn();
const id = registry.setTimeout(callback, 500);

scheduler.update(500);
expect(callback).toHaveBeenCalledTimes(1);

// cancel after fire should not throw
expect(() => registry.cancel(id)).not.toThrow();
});
});
37 changes: 37 additions & 0 deletions src/scheduler/timeout-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Scheduler, { TimerId } from './scheduler';

export class TimeoutRegistry {
private timeouts: Set<TimerId> = new Set();
private scheduler: Scheduler;

constructor(scheduler: Scheduler) {
this.scheduler = scheduler;
}

setTimeout(callback: () => void, delay: number): TimerId {
const timerId = this.scheduler.setTimeout(() => {
this.timeouts.delete(timerId);
callback();
}, delay);
this.timeouts.add(timerId);
return timerId;
}

cancel(timerId: TimerId | null | undefined): void {
if (timerId === null || timerId === undefined) {
return;
}
this.scheduler.cancel(timerId);
this.timeouts.delete(timerId);
}

cancelAll(): void {
try {
for (const timerId of this.timeouts) {
this.scheduler.cancel(timerId);
}
} finally {
this.timeouts.clear();
}
}
}