SP slotplate
Concept

Timing (no setTimeout)

Why game code never calls setTimeout — and what it calls instead.

Principle #2: game code never calls setTimeout or setInterval. All scheduled work goes through Ticker.schedule(ms, fn) from src/infrastructure/timing.ts.

The bug

BROKEN · setTimeout setTimeout runs on the platform clock. TAB VISIBLE pixi ticker · animation runs setTimeout · fires on schedule TAB HIDDEN (paused) pixi ticker · paused hidden CORRECT · Ticker.schedule gsap.delayedCall on app.ticker. TAB VISIBLE pixi ticker GSAP synced to pixi.ticker TAB HIDDEN (paused) pixi ticker · paused (also paused — in sync)
setTimeout keeps firing in hidden tabs. Pixi's ticker pauses. The two desync.

The user switches tabs. Pixi's ticker pauses — requestAnimationFrame is throttled or suspended by the browser. Good: the game freezes in place.

Except setTimeout doesn't pause. That win-show callback you queued with setTimeout(..., 1500) fires while the reels haven't moved. Now the balance updates before the animation plays. When the user switches back, you get a ghost animation and (on a cascade game) a phase mismatch that crashes the FSM.

The fix

gsap.delayedCall runs on GSAP's ticker. slotplate syncs that ticker to app.ticker at boot:

// src/infrastructure/timing.ts
export function syncGsapToPixi(pixiTicker) {
  gsap.ticker.remove(gsap.updateRoot);
  pixiTicker.add(() => gsap.updateRoot(performance.now() / 1000));
}

Now GSAP pauses when Pixi pauses. Ticker.schedule wraps gsap.delayedCall and returns a Disposable:

schedule(delayMs: number, fn: () => void): Disposable {
  const tween = gsap.delayedCall(delayMs / 1000, fn);
  return { dispose: () => tween.kill() };
}

Usage

// In a phase's enter():
this.cancel = ctx.ticker.schedule(1500, () => {
  ctx.fsm.transition('idle');
});

// In the phase's exit():
this.cancel?.dispose();

Enforcement

biome.json declares setTimeout and setInterval as restricted globals with a message pointing at timing.ts. Lint will fail any PR that adds them.

What about Promises?

  • Promises that resolve on ticker events (reel landed, animation done) — fine.
  • Promises that resolve on wall-clock delay via setTimeoutsame bug as raw setTimeout. Use the ticker.

What about network timeouts?

AbortController + fetch's built-in timeout are infrastructure-layer concerns. They deliberately run on wall-clock time — you want the request to fail after N seconds of real time even if the tab is hidden. The rule is about game timing (sequences, animations, debounces), not network.