System architecture
The drawn version of this, for evaluators, is on Architecture. This page is the detailed account.
Ten repositories, deployed independently
Section titled “Ten repositories, deployed independently”Nothing here is a monorepo. Each repository has its own history, its own CI and its own release cadence; what binds them is a set of versioned contracts, not a build.
| Repository | What it is |
|---|---|
website |
The Node/Express + MariaDB + React site. The only internet-facing web app |
Module-uo |
All the Ultima Online code, installed into the site as a module |
link |
The uo-link sidecar, in Rust — the only network-facing bridge component |
servuo-plugins |
The in-game plugin, C#, that feeds the sidecar |
installer |
Deploys the shard side: sidecar plus plugin overlay |
Android-app |
Native Android client of the website API |
Integration-kit |
The instruction book for putting a different game on the platform |
docs |
Canonical design docs and the protocol spec |
runicgateway.com |
This site |
.profile |
The organisation landing page |
The layers
Section titled “The layers” Browser (React SPA) Native Android app │ cookie │ bearer └──────────┬─────────────────┘ ▼ ┌────────────────────────┐ │ website (Node) │ │ middleware → router │ │ → controller → model │ │ → db │ └───────┬────────────┬───┘ │ │ loads at boot ▼ ▼ MariaDB modules/<id>/ ← installed, never built │ ▼ the game, via whatever bridge that module ownsThe backend is strictly layered — middleware → router → controller → model → db — with
models in .model.js (logic) and .db.js (SQL) pairs, and raw parameterised queries with
no ORM anywhere.
Core is game-agnostic
Section titled “Core is game-agnostic”Since the module system shipped on 2026-08-12, nothing in core knows about any particular game. Routes, tables, pages, navigation and push streams for a game arrive from a module the operator installed. Core provides the seams; the module fills them.
That is why the architecture below describes module-uo as the worked example rather than
as part of the platform. It is the module every other module is measured against, not a
component core depends on.
The invariants
Section titled “The invariants”These hold across repository boundaries, and every one of them is load-bearing.
The game is never network-reachable
Section titled “The game is never network-reachable”The ServUO shard dials out over loopback TCP 127.0.0.1:7788, newline-delimited JSON,
to the sidecar. The sidecar is the listener; the game opens no port. Only the sidecar is
exposed, and only the website’s backend talks to it.
See The bridge.
A wedged sidecar can never stall the game
Section titled “A wedged sidecar can never stall the game”On the C# side, Emit() enqueues onto a bounded, drop-oldest queue and returns
immediately. It never touches the socket from the game’s core thread. Every world read
happens on the core thread; a dedicated writer thread drains the queue.
Dropping game events is strictly better than pausing the game to deliver them.
The website degrades rather than fails
Section titled “The website degrades rather than fails”The sidecar REST client never throws — every call returns { ok, data, status }. The public
site still renders with the shard shown offline.
That guarantee covers reading the configuration too: resolving the admin-managed config
decrypts a stored token, which throws if the ciphertext cannot be authenticated (a rotated
SECRET_ENC_KEY, or a database dump restored under a different key). That is caught inside
the client and reported as unavailable, so a wrong key degrades the shard surface instead of
500-ing it — and the admin config screen keeps working, which is the screen you need in order
to recover.
Sensitive events never reach the public
Section titled “Sensitive events never reach the public”Ingested events fan out over two SSE channels: a public allowlist stream, and an admin-only stream that additionally carries staff audit, cheat detection and login attempts with IPs.
The catalog is the module’s; the boundary is core’s. A module declares which of its kinds are public-safe, and core enforces the split. A sensitive kind cannot reach the public channel.
A failed module never takes the site down
Section titled “A failed module never takes the site down”The loader catches failures across a module’s entire lifecycle and marks it
startup_failed. The site comes up with that module’s routes and navigation absent, and the
admin panel says why. See Module
lifecycle.
Secrets are encrypted at rest
Section titled “Secrets are encrypted at rest”OAuth client secrets, the sidecar token, the Discord bot token and the mail transport’s
credentials are AES-256-GCM encrypted, keyed by SECRET_ENC_KEY. The sidecar token and the
mail credentials are write-only in the API — neither is ever returned to any client; the
email panel reports only that a password is set.
A deploy is two independent installs
Section titled “A deploy is two independent installs”Worth stating plainly, because it is the single most common misunderstanding: the installer binary sets up the shard side only, and never contacts the website. The website is a separate Docker deployment on, usually, a different machine.
The installation path walks both in order.
Canonical documents
Section titled “Canonical documents”ARCHITECTURE.md
holds the canonical diagram, and
BACKEND_DESIGN.md
is the full API, schema and security contract. See Canonical
documents for the whole map.