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

Rooms and Workers

Audience: Core contributors, AI agents · Prereqs: Repo Layout

Canonical source: server-game/src/roomManager.js (main thread), server-game/src/worker.js (worker entrypoint), server-game/src/rooms.js (room simulation)

The game server's most important structural fact: it doesn't simulate rooms on its main thread at all. Every room runs in its own dedicated node:worker_threads Worker, and the main thread's job is reduced to socket I/O and a thin relay.

At a glance

Main thread(roomManager.js)- Owns real player WebSockets- searchRooms(): create / join- Never simulates gameplay itself- Rebinds a joined player's socket to postMessage into their room- Handles worker.on('message'): switch(Comm.Worker enum) send / close / updateRoom / boot / closeAllWs / terminateSpare worker (warm, idle)instantiateSS() + loadPlugins('game')already ran - waiting for createRoomRoom #1 workerown instantiateSS() +own loadPlugins('game') +own RoomConstructorRoom #2 workerown instantiateSS() +own loadPlugins('game') +own RoomConstructorpostMessage(setSS)postMessage: createRoom / wsMessagereply: Comm.Worker enumpostMessage: createRoom / wsMessagereply: Comm.Worker enum

Every worker box - spare or claimed - independently ran the full boot sequence (instantiateSS + loadPlugins('game')) with zero shared state between them or with the main thread, as described below.

The warm-spare-worker pattern

RoomManager keeps exactly one spare worker alive at all times, created proactively rather than on demand:

async createRoomWorker() {
    this.roomWorker = new Worker(new URL('./worker.js', import.meta.url));
    this.roomWorker.postMessage(["setSS", {
        maps: ss.maps, items: ss.items, permissions: ss.permissions, config: ss.config,
    }]);
}

getRoomWorker() {
    const oldRoom = this.roomWorker;   // hand out the currently-warm one
    this.createRoomWorker();           // ...and immediately start spinning up its replacement
    return oldRoom;
};

getRoomWorker() is called exactly once, from createRoom(), every time a new room is actually needed. The effect: creating a room never pays worker-thread startup latency (module loading, plugin loading - see below) on the critical path, since there's always already a worker that finished that startup work sitting idle, waiting to be handed a room. The moment one spare gets claimed, a new one immediately starts warming up to replace it.

Each worker independently re-runs the entire server boot sequence

worker.js is the actual script each thread runs, and it does not inherit anything from the main thread's own boot beyond what's explicitly passed via postMessage:

(async () => {
    misc.instantiateSS(import.meta, process.argv);
    await plugins.loadPlugins('game');                          // full, independent plugin load
    const RoomConstructor = (await import('#rooms')).default;   // imported after, so plugins can patch it first
    parentPort.on('message', (msg) => { /* setSS / createRoom / joinPlayer / wsMessage / wsClose / exit */ });
})();

This means every worker thread - including the spare one that's just sitting idle waiting for a room - has its own complete, independent set of plugin instances, with no shared memory or state with the main thread's plugin instances, or with any other worker's. See Workers and State for the plugin-author-facing consequences of this.

We confirmed this directly, empirically, while validating a plugin example: booting a game server with one simple plugin installed produces two independent, complete "Loaded plugin" log lines, milliseconds apart - one from the main thread's own loadPlugins('game') call (run-game.js), one from the spare worker's independent call inside worker.js. Once a real room is created, that room's worker produces a third, and so on, one per room.

The main thread's actual job: routing, not simulating

Once RoomConstructor is instantiated inside a worker (on receiving a "createRoom" message), the worker owns everything about that room's gameplay. The main thread (roomManager.js) does three things:

  1. Room lookup/creation (searchRooms) - handles create-private, join-private-by-id, and join-public flows, including a flat 10% chance of spinning up a new room even when a joinable one already exists, to spread players across different maps rather than funneling everyone into whichever room happened to exist first.
  2. Relaying raw player messages into the correct worker. Once a player joins a room, joinRoom rebinds their socket's message/close handlers to postMessage(["wsMessage"/"wsClose", content, wsId]) into that room's worker - from this point on, the main thread never inspects the message content, it's a dumb pipe.
  3. Executing outbound commands the worker can't do itself. A worker can't touch a real WebSocket directly (sockets aren't transferable to worker threads the way this architecture uses them), so it posts back a small, fixed enum of commands instead - Comm.Worker (src/shell/comm.js):
Worker: {
    send: 0,          // send a buffer to one client
    close: 1,          // close a client's connection
    updateRoom: 2,      // push updated room metadata to the main thread
    boot: 3,            // forcibly disconnect a client
    closeAllWs: 4,       // close every client in the room
    terminate: 5,        // the room is done; main thread should terminate this worker
},

The main thread's worker.on('message', ...) handler switches on this enum and performs the actual socket operation on the worker's behalf.

gameKey is hardcoded to spell "LS" in base 36

createRoom(info) sets info.gameKey = 784 unconditionally, with the original randomized version (Math.getRandomInt(10, Math.pow(36, 2) - 10)) left commented out directly above it. This looks like an arbitrary debug leftover, but it isn't: a room's shareable join code is built by rendering gameId/gameKey in base 36 ((room.gameKey).toString(36), roomManager.js:367), and (784).toString(36) is "ls" - 784 is deliberately the base-36 encoding of LS ("LegacyShell"), so every room's join code ends in LS by design, not by accident. It does still remove real entropy from that portion of the code (every room's trailing two characters are now identical, where the commented-out version varied them across the full 36² range) - worth knowing if you're relying on gameKey for anything that needs it to actually vary. See Known Quirks.


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
Game Loop
Next
Wire Protocol