Game Loop
Audience: Core contributors, AI agents · Prereqs: Rooms and Workers
Canonical source:
src/shell/general/looper.js(the scheduler),server-game/src/rooms.js(updateLoop)
The authoritative 60Hz simulation loop every room runs, and the reconciliation mechanism that keeps client prediction honest.
At a glance
The scheduler: createLoop
src/shell/general/looper.js (its own comment: "lifted directly from RTW's server") implements a hybrid coarse/fine timer specifically to hit a target tick rate accurately without the memory-leak footgun of very short setTimeout intervals:
export default function createLoop(update, tickLengthMs) {
// ...
let longwaitMs = Math.floor(tickLengthMs - 1);
// ...
let gameLoop = function () {
let now = getMicro();
if (now >= target) {
let delta = now - prev;
prev = now;
target = now + tickLengthMicro;
update(delta * micro2s); // run user code, delta in seconds
};
let remainingInTick = target - getMicro();
if (remainingInTick > longwaitMicro) {
timeoutId = setTimeout(gameLoop, Math.max(longwaitMs, 16)); // coarse wait, floored at 16ms
} else {
setImmediate(gameLoop); // fine-grained wait for the last stretch
};
};
gameLoop();
return { stop: () => { /* ... */ } };
};
The 16ms floor on the setTimeout branch is deliberate - the source comment is explicit that going below it causes a Node memory leak, so accuracy is traded for stability there; the remaining sub-16ms precision comes from the setImmediate fine-grained branch once the loop is close enough to its target time. TickStep (1000 / ticksPerSecond, ticksPerSecond = fps = 60 - see src/shell/constants.js) is the default tick length if none is passed.
What runs on which schedule
Each room sets up five independent loops on construction, all built on createLoop:
| Loop | Interval | Purpose |
|---|---|---|
updateLoop | TickStep (~16.67ms, 60Hz) | The main simulation tick - see below. |
dataSyncLoop | 1000ms | Less time-critical per-client data, kept off the main sync to reduce its payload size. |
metaLoop | 2000ms | Idle-kick checks, weather triggers, empty-room destruction. |
updateRoomDetails | 30000ms | Pushes room metadata back to the main thread. |
spawnItems | 30000ms | Fallback/catch-up item spawning (items also spawn reactively, this is a periodic backstop). |
Inside updateLoop: catch-up, replay, and prediction
async updateLoop (delta) {
var currentTimeStamp = Date.now();
plugins.emit('roomUpdate', {this: this, delta, currentTimeStamp});
while (this.lastTimeStamp < currentTimeStamp) { // catch up if a previous tick ran long
this.lastTimeStamp += TickStep;
this.munitionsManager.update(1);
await iteratePlayersAsync(async player => {
plugins.emit('playerUpdate', {this: this, player, delta, currentTimeStamp});
if (!player.client.isHuman) {
await player.update(1); // bots: just simulate
} else if (player.stateIdx !== player.syncStateIdx) {
while (player.stateIdx !== player.syncStateIdx) { // replay buffered real input
plugins.emit('playerStateUpdate', {this: this, player, delta, currentTimeStamp});
await player.update(1);
player.resetPrediction();
};
} else {
player.predictUpdate(1); // no new input yet: keep predicting
};
player.incrementStatesUsed();
});
this.serverStateIdx = Math.mod(this.serverStateIdx + 1, stateBufferSize);
if (this.serverStateIdx % FramesBetweenSyncs === 0) {
await this.sync(); // ~10Hz (FramesBetweenSyncs = ceil(60/10) = 6), full-state push
};
};
};
Three things worth pulling out:
- The
whileloop is a catch-up mechanism, not a fixed one-tick-per-call assumption. If the scheduler's callback fires late (the process was busy, GC paused, etc.), this processes as many simulation ticks as needed to bringlastTimeStampback up to real time, rather than silently running slow. A room under heavy load falls behind in wall-clock terms per call, but the simulation itself doesn't skip ticks. - Human players branch three ways, not two: a bot always just simulates; a human player with buffered real input pending (
stateIdx !== syncStateIdx) replays that input tick by tick (the authoritative reconciliation path); a human player with no new input yet runspredictUpdate(extrapolating forward) instead of stalling. This is the concrete mechanism behind the client-prediction pattern described throughout Plugin Development - the server is doing its own version of "predict, then correct when real data arrives," not just trusting whatever the client last reported. - Full-state sync happens every
FramesBetweenSyncsticks (6, at the default 60Hz/10Hz ratio), gated by a modulo check onserverStateIdx- not its own separate timer, but derived from the same counter the state ring buffer uses.
The state ring buffer
stateBufferSize = 256 (see src/shell/constants.js) - at TickStep (~16.67ms) per entry, that's roughly 4.3 seconds of buffered per-player state history, used for the replay/reconciliation described above. This lives on the Player object itself (player.js), shared by the exact same class running client-side (for local prediction) and server-side (for authoritative replay) - see Shared Shell Layer.
This page was drafted with AI assistance and reviewed for accuracy. If something looks wrong, please open a PR or flag it.
