Skip to content

System architecture

The drawn version of this, for evaluators, is on Architecture. This page is the detailed account.

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
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 owns

The 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.

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.

These hold across repository boundaries, and every one of them is load-bearing.

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.

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 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.

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.

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.

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.

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.

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.