LegacyShell WikiLegacyShell Wiki
Back to LegacyShell
Home
Wiki
Plugins
Docs
Back to LegacyShell
Home
Wiki
Plugins
Docs
  • Documentation

    • Getting Started

      • What is LegacyShell?
      • Speed Setup
      • Requirements
      • Installation
      • First Run
      • Config Files
      • Making an Account
      • Troubleshooting (Getting Started)
    • Running a Server

      • Architecture Overview
      • The Database
      • Users and Ranks
      • Adding Game Servers
      • Client Mirrors
      • Perpetual
      • Backups
      • Rate Limiting
      • Moderation
      • Closed Mode
      • Deployment
      • Troubleshooting (Running a Server)
      • Hosting for Someone Else's Instance
    • Content Creation

      • Maps
      • Dealing with Babylon Models
      • Map Blocks
      • Items and Skins
      • Hats and Stamps
      • Sounds
      • Gamemodes
      • Seasonal Events
    • Plugin Development

      • Quickstart
      • I Want To...
      • Anatomy of a Plugin
      • Lifecycle
      • Dependencies
      • Events (Concept)
      • Event Reference

        • services: events
        • game: events — shared logic (src/shell/)
        • game: events — main-thread server process
        • game: events — per-connection client object
        • game: events — room lifecycle & tick loop
        • game: events — in-browser gameplay
        • client: events — client server & build pipeline
      • Commands
      • Client-Side Code
      • Static Assets
      • Content Packs
      • Networking
      • Workers and State
      • Prediction and Authority
      • Recipes

        • Recipe: Killstreaks
        • Recipe: New Pickup Item
        • Recipe: New Gamemode
        • Recipe: Custom Weapon
        • Recipe: UI Modification
        • Recipe: Discord Integration
        • Recipe: Replacing Core Behaviour
        • Recipe: Persistent Plugin Storage
        • Recipe: Rewarding Players with Currency
        • Recipe: Custom Per-Player Data
        • Recipe: Custom Theme
      • Publishing
      • Pitfalls
      • Modifiers
      • Sound and Apollo
    • Codebase Reference

      • Repo Layout
      • Shared Shell Layer
      • Server-Only Markers
      • The ss Object
      • Build Pipeline
      • Stamps and Babylons
      • Game Loop
      • Rooms and Workers
      • Wire Protocol
      • Generated

        • Wire Protocol Opcodes
        • Enums & Lookup Tables
        • Database Schema
        • Config Reference
        • Slash Command Reference
      • Services Internals
      • Catalog and Items
      • Permissions Internals
      • Physics and Collision
      • Known Quirks
      • Codebase Anecdotes
      • Development Timeline
    • Contributing

      • Documentation Style Guide
      • Generators
      • For AI Agents

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

updateLoop - 60Hz (TickStep ≈ 16.67ms) tick 0sync() - tick 6full-state push, ~10Hzsync() - tick 12FramesBetweenSyncs = 6dataSyncLoopevery 1000msless time-critical per-client datametaLoopevery 2000msidle-kick, weather, empty-room destroyupdateRoomDetails /spawnItemsevery 30000msmetadata push + fallback spawningAll five loops are independent timers built on the same createLoop scheduler - not phase-locked to each other.

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:

LoopIntervalPurpose
updateLoopTickStep (~16.67ms, 60Hz)The main simulation tick - see below.
dataSyncLoop1000msLess time-critical per-client data, kept off the main sync to reduce its payload size.
metaLoop2000msIdle-kick checks, weather triggers, empty-room destruction.
updateRoomDetails30000msPushes room metadata back to the main thread.
spawnItems30000msFallback/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:

  1. The while loop 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 bring lastTimeStamp back 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.
  2. 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 runs predictUpdate (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.
  3. Full-state sync happens every FramesBetweenSyncs ticks (6, at the default 60Hz/10Hz ratio), gated by a modulo check on serverStateIdx - 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.

Edit this page on GitHub
Prev
Stamps and Babylons
Next
Rooms and Workers