Architecture Overview
Audience: Server operators · Prereqs: Getting Started
Canonical source:
server-services/,server-game/,server-client/,src/shell/general/misc.js
What is LegacyShell? introduced the three servers at a glance. This page goes one level deeper - what you actually need to know to operate a real deployment, especially once it's more than "everything on one machine."
At a glance
A visitor's browser only ever talks to a client server (to download the game) and a game server (to actually play) - it never talks to services directly. Both server roles poll services independently, and each caches the last response locally so a brief services outage doesn't take the whole fleet down (see "How they find each other" below).
The three roles, in detail
| Role | Directory | Protocol / default port | Can you run more than one? |
|---|---|---|---|
| Services | server-services/ | WebSocket only, :13371 | No (not without extra work to sync data across instances) - this is the single source of truth. |
| Game | server-game/ | WebSocket (players) :13372, plus an outbound connection to services | Yes - add as many as you want, each registered against your services server. |
| Client | server-client/ | HTTP, :13370 | Yes - unauthenticated mirrors, point them at the same services server. |
Services: the single source of truth
Services owns the SQLite database (server-services/store/LegacyShellData.db) - accounts, sessions, item/map definitions, redemption codes, and the list of authorized game servers. It's the only server that talks to the database directly. There should be exactly one services server per deployment (see The Database).
Its entire external interface is a raw WebSocket server - no HTTP, no REST API. Every message is a small JSON envelope ({ cmd: "...", ... }), and it applies per-IP rate limiting to most commands (see Rate Limiting) - except for connections presenting a valid auth_key, which bypass rate limiting entirely (see Adding Game Servers).
Game: where matches actually happen
Each game server is authoritative for the matches it's running - physics, hit detection, scoring - and pushes state updates to connected players over its own WebSocket. It doesn't touch the database directly; instead it talks to services for anything account-related (recording a kill, checking a session).
Internally, each game room (a single ongoing match) runs in its own dedicated worker thread, isolated from every other room on the same server - useful to know if you ever get into plugin development, covered in Codebase Reference, but not something you need to think about just to operate a server.
A game server must be authorized by the services server owner before it will do anything useful - see Adding Game Servers.
Client: what your browser actually downloads
The client server is a plain Express web server, serving the built browser game (HTML/JS/assets) plus the integrated wiki. It's unauthenticated by design - anyone can run a client mirror pointed at your services server, the same way anyone could run a mirror of a normal website. It builds its own JavaScript bundle on startup (merging the shared game-logic code that's also used server-side - not something you need to touch as an operator, but if a first boot seems to take a while, this is why).
How they find each other
Game and client servers don't just start talking to a services server blindly - each one polls services on startup (and periodically afterward) with a requestConfig request, asking for the current maps, items, list of authorized servers, and any live config services wants to push out. Both cache the last response to local disk (store/maps.json, items.json, servers.json), so if services is briefly unreachable, a game or client server can still boot using the last-known-good data rather than refusing to start.
If services restarts, every connected game/client server notices (it reports a newer startTime on the next poll) and restarts itself to pick up the change - this is what the Perpetual process manager's auto-restart is for.
A single-machine deployment
Running everything on one computer (what Getting Started walks through) is just the smallest valid version of this architecture: one services, one game, one client, all pointed at localhost. Nothing about the architecture changes - there's just nothing to distribute yet.
A multi-region deployment
Scaling out looks like:
- One services server, somewhere central.
- Multiple game servers, one per region you want to offer, each added to services'
game_serverstable with its ownauth_key(see Adding Game Servers). - One or more client mirrors, each pointed at the same services server via
sync_serverinclient.yaml(see Client Mirrors).
Players pick a game server from the in-game server list (which services assembles from the game_servers table and each server's live player counts), but always download the game itself from whichever client mirror they happened to visit.
This page was drafted with AI assistance and reviewed for accuracy. If something looks wrong, please open a PR or flag it.
