Skip to main content

The Game Loop ⏱️

gameplate ships a deterministic, abstract game loop. Two modes — pick the one your game actually needs.

Variable timestep (default)

Calls update(dt) and render(0) every frame, where dt is "seconds since the previous frame":

const game = createGame({
state,
actions,
update: (state, dt, actions) => {
actions.move(state.vx * dt, state.vy * dt);
},
render: (state) => draw(state),
});

✅ Simple, intuitive. ✅ Smooth at any framerate. ❌ Physics integration is frame-rate dependent — collisions and accumulation drift over time.

Use for: turn-based games, casual arcade games, UI prototypes, menus, anything where "close enough" timing is fine.

Fixed timestep (opt-in)

Calls fixedUpdate(dt) N times per frame with a constant dt, then render(alpha) once with an interpolation factor:

const game = createGame({
state,
actions,
fixedStep: 1 / 60, // 16.666 ms per physics tick
fixedUpdate: (state, dt, actions) => {
physics.step(dt); // dt is *always* 1/60
},
render: (state, alpha) => {
// alpha ∈ [0, 1) — interpolate between previous and current physics state
renderer.draw(lerp(prev, state, alpha));
},
});

Deterministic — given the same inputs, the same simulation runs identically every time. ✅ Reproducible — record/replay, network sync, headless test fixtures all just work. ❌ Slightly more code in the renderer to interpolate.

Use for: anything with physics, multiplayer games, anything that needs to be reproducible.

Why both?

Both update and fixedUpdate are called when fixedStep is set — update once per frame, fixedUpdate 0..N times per frame. Use the right one for the right job:

update: (s, dt, actions) => actions.pollInput(), // every frame
fixedUpdate: (s, dt, actions) => actions.physicsStep(), // every 1/60s, no matter the framerate
render: (s, alpha) => draw(s, alpha), // every frame

Spiral-of-death protection

If your tab pauses (Alt-Tab, debugger break, slow tick), dt could spike to several seconds. With fixed-step physics that means "do 200 ticks to catch up", which can lock the page.

gameplate caps dt at maxDelta (default 0.25 seconds):

const game = createGame({
state,
actions,
maxDelta: 0.05, // never run more than 50ms-equivalent of catch-up per frame
});

Lower-level: createLoop

When you don't want the state/input layers, createLoop is the bare game loop:

import { createLoop } from 'gameplate';

const loop = createLoop({
update: (dt) => console.log('frame, dt =', dt),
render: (alpha) => console.log('render'),
});

loop.start();
loop.stop();
loop.isRunning();

Schedulers

The loop is decoupled from the time source. Three schedulers are bundled:

SchedulerWhenTime source
browserScheduler()Browser, defaultperformance.now + requestAnimationFrame
nodeScheduler(hz?)Node, defaultperformance.now + setTimeout(1000/hz)
defaultScheduler()Auto-pickthe right one for the runtime

You can write your own — useful for tests, deterministic record/replay, or custom runtimes:

import { createGame, type Scheduler } from 'gameplate';

let now = 0;
let cb: ((t: number) => void) | undefined;
const fakeScheduler: Scheduler = {
now: () => now,
schedule: (callback) => {
cb = callback;
return () => {
cb = undefined;
};
},
};

const game = createGame({ state, actions, scheduler: fakeScheduler });
game.start();

// Drive the loop manually:
now += 16;
cb?.(now);
now += 16;
cb?.(now);

This is exactly how gameplate's own tests achieve millisecond-precise loop coverage without ever touching real time.

Choosing in practice

┌───────────────────────────────────┐
│ Do you have physics / determinism │
│ requirements? │
└─────────────┬─────────────────────┘

┌──── no ─────┴───── yes ────┐
▼ ▼
variable timestep fixed timestep
(update + render only) (fixedUpdate + render)

If you're unsure, start with variable. You can opt into fixed-step later by setting one config field — no API changes elsewhere.