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
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:
- 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. - Relaying raw player messages into the correct worker. Once a player joins a room,
joinRoomrebinds their socket'smessage/closehandlers topostMessage(["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. - 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.
