Skip to content

Simulation loop

IdleKit runs a fixed-time-step simulation loop (SimulationLoop implements the classic fixed-timestep game-loop pattern): the simulation advances in constant increments regardless of frame rate. This page explains why, and how to drive the loop in the browser, offline and in tests.

Fixed step, variable frames

Each frame, the loop measures the real elapsed time, accumulates it, and runs update(dt) zero or more times with a constant dt (default 1/20 s), then renders once:

txt
frame ──► accumulate elapsed ──► while (accumulated >= step) update(step) ──► render

Consequences worth internalizing:

  • Determinism — 30 fps, 144 fps and a headless test produce identical results, because systems only ever see the same dt.
  • Stability — non-linear mechanics (thresholds, caps, feedback loops) behave identically whatever the display; there is no "fast machine earns more" bug.
  • Spiral-of-death protection — a frame's catch-up is capped (maxFrameTime, default 0.25 s), so a long GC pause or breakpoint doesn't freeze the game in a catch-up loop.

Configure the step at creation:

ts
const engine = createEngine<State>({ initialState, step: 1 / 10 }); // 10 Hz

Coarser steps are cheaper; finer steps make short cooldowns smoother. 1/20 s is a good default for idle games.

Driving frames: the scheduler

The loop never calls requestAnimationFrame itself — frames come from an injected FrameScheduler:

ts
// Browser: rAF-driven
import { createRafScheduler } from "@idlekitjs/browser/raf-scheduler";
const engine = createEngine<State>({ initialState, scheduler: createRafScheduler() });

engine.start(); // ticks on every animation frame
ts
// Headless (default when omitted): nothing ticks on its own
const engine = createEngine<State>({ initialState });

engine.start(); // arms the loop, but...
engine.advance(60); // ...you advance the simulation explicitly

For tests that need frame-level control (render timing, pause behavior), use manualScheduler and push frames by hand.

Lifecycle

CallEffect
engine.start()Begin driving frames through the scheduler.
engine.stop()Stop frames entirely.
engine.pause()Suspend without ending the run (tab hidden). No time accumulates.
engine.resume()Resume after a pause. The first frame back has a zero delta — no time jump.
engine.dispose()Stop and run every extension's teardown.

You rarely call pause/resume yourself — the page-lifecycle bridge does it on tab changes and emits the elapsed background time for offline progress to credit.

Offline catch-up: advance(seconds)

engine.advance(seconds) advances the simulation by running the same fixed steps as real time (plus one final partial step):

ts
engine.advance(3600); // one hour, in 1/20 s steps

This is deliberately not a single update(3600) call: idle mechanics are non-linear (caps, thresholds, produce→spend feedback), and one giant step would distort them. Fixed-step catch-up guarantees offline time behaves exactly like attended time.

Cap offline time in the game

advance applies whatever you pass. Capping (8 h? 24 h?) is a game-design decision, so it lives with you — or use the offline-progress plugin, which caps and calls advance for you.

Mechanics are written to handle large dt totals gracefully: producers credit every completed cycle, crafting completes due jobs, boosts expire — see each mechanic's page.

Update order within a step

For each fixed step, the engine runs:

  1. every system, in registration order;
  2. every extension update, in registration order.

Then, once per frame (not per step): state flush → renderer → extension render hooks. Keep systems order-independent when you can; when you can't, registration order is the contract.