Skip to content

The module system

Runic Gateway’s core knows nothing about any particular game. Everything that makes the site a Ultima Online site — the shard status, the atlas, the market, the guild pages — lives in a module, installed onto a running deployment.

This section is the builder’s track. If you only want to install one, that is Install a game module and Managing modules.

One repository producing one bundle, with a server half and a client half that version together — so a route and the screen that calls it can never be mismatched.

A module owns:

  • Its routes, server and client
  • Its schema, as a fragment core replays on boot
  • Its navigation entries, interleaved into core’s groups rather than parked in a section of their own
  • Its vocabulary — the words a player of that game expects

Core owns the account, the session, the roles, the posts, the uploads, notifications and Teams. A module reaches all of that through a defined surface, the module API.

Three constraints had to hold at the same time, and between them they determined almost everything else:

  1. Production is a prebuilt, pull-only image. Operators do not build. There is no compile step anywhere in installing a module.
  2. Modules live on a mounted volume, not inside the image — a bind mount of ./modules. That is what lets a module be added to an image that knows nothing about it.
  3. script-src 'self'. The content-security policy forbids inline script, which rules out an import map and is why core shares React on a global instead. See Building a module.

Install and uninstall need a restart — never a rebuild.

A module runs in the same Node process, with full access. Say that plainly, because the word “module” invites the opposite assumption.

The boundary is a code-organisation and distribution boundary, not a security boundary. For a self-hosted operator installing software they chose, that is the same trust category as running its schema fragment — which they are also doing.

What the boundary buys is that modules talk to core through a defined surface, so a core refactor cannot silently break a module. That rule is enforced mechanically rather than by review: a module must run with zero require/import reaching outside its own directory, and CI checks it. A gap in the surface extends the surface; it is never worked around with a deeper import.

A module owns one path segment wherever it appears. For a module with id uo:

Surface Path
Public pages /uo/shard, /uo/atlas, /uo/market
Admin pages /admin/uo/link, /admin/uo/visibility
Player pages /player/uo/…

API routes are the exception, and keep their exact paths. The shard admin API is still /api/v1/admin/shard/*, not /api/v1/admin/uo/shard/*. This is why the Android app and the Discord bot needed no API changes at the cutover.

Old paths are not redirected. That was a deliberate call — a visible boundary in the URL rather than a hidden one — taken while the platform had no public deployments to break.

This page summarises. The normative document is MODULE_SYSTEM.md, and the contract itself is MODULE_API.md. Where this site and those documents disagree, they are right and this is a bug.