Headless games 🖥️
Run your game without a DOM. The same code, same API — just no window, no canvas, no keyboard. Perfect for:
- Server-authoritative multiplayer. Run the simulation on Node, ship state to clients.
- CI tests. Drive 5,000 ticks of the game in a Vitest run; assert outcomes.
- Web Workers. Move heavy simulation off the main thread.
- Replay systems. Re-run a recorded input stream with a deterministic loop — see Recording & Replay for the built-in primitives.
How gameplate stays headless-safe
Three design choices make it work:
- Input is no-op on the server.
createKeyboard(),createPointer(), andcreateGamepad()return stubs in any environment without the matching platform API — same shape,false/0/ never-fires across the board. - The loop is environment-aware.
defaultScheduler()picksrequestAnimationFramein browsers andsetTimeout-based scheduling in Node. - State + actions don't touch the DOM. That's just on you to keep that way.
A minimal headless game
import { createGame, defineActions, nodeScheduler } from 'gameplate';
type State = { tick: number; players: { id: string; x: number; y: number }[] };
const actions = defineActions<State>()({
step: (s) => ({ ...s, tick: s.tick + 1 }),
move: (s, id: string, dx: number, dy: number) => ({
...s,
players: s.players.map((p) => (p.id === id ? { ...p, x: p.x + dx, y: p.y + dy } : p)),
}),
});
const game = createGame({
state: { tick: 0, players: [] },
actions,
scheduler: nodeScheduler(60), // 60 Hz, no rAF needed
update: (state, dt, actions) => actions.step(),
});
game.start();
// Push to clients on every tick:
game.subscribe((next) => broadcast(next));
That's it — runs on Node, never touches window.
Driving the loop manually (for tests / replay)
Inject a custom scheduler to step the loop yourself:
import { createGame, type Scheduler } from 'gameplate';
let now = 0;
let pending: ((t: number) => void) | undefined;
const manualScheduler: Scheduler = {
now: () => now,
schedule: (cb) => {
pending = cb;
return () => {
pending = undefined;
};
},
};
const game = createGame({ state, actions, scheduler: manualScheduler });
game.start();
function tickOnce(deltaMs = 16) {
now += deltaMs;
pending?.(now);
}
// Run 600 frames (~10 seconds at 60 Hz):
for (let i = 0; i < 600; i++) tickOnce();
assertEqual(game.state().tick, 600);
This is exactly how gameplate's own tests run thousands of frames in milliseconds without
any real time passing.
Web Workers
Same trick — pick the right scheduler. requestAnimationFrame is available in Workers via
globalThis.requestAnimationFrame in modern runtimes, but if it isn't, fall back:
import { createGame, browserScheduler, nodeScheduler } from 'gameplate';
const scheduler =
typeof requestAnimationFrame === 'function' ? browserScheduler() : nodeScheduler();
const game = createGame({ state, actions, scheduler });
Determinism caveats
A loop that starts deterministic doesn't stay that way if you sprinkle non-determinism inside your actions. To run reproducible simulations:
- ✅ Don't read
Math.random()directly — accept a seeded PRNG in your state, e.g. viapure-rand. - ✅ Don't read
Date.now()— pass time throughdt. - ✅ Don't iterate
Map/Setby insertion order if you ever merge state from elsewhere.
Stick to those rules and you can record a stream of (tick, event) tuples and replay them
to bit-identical state. That's the whole foundation of rollback netcode.
Sharing code between client and server
Put state types and action definitions in a shared module:
import { defineActions } from 'gameplate';
export type State = {
/* ... */
};
export const actions = defineActions<State>()({
/* ... */
});
Then both client.ts (with renderer + input) and server.ts (headless) import it. Same
guarantees, same bugs, same fixes.