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.
What a module is
Section titled “What a module is”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.
Why it is built this way
Section titled “Why it is built this way”Three constraints had to hold at the same time, and between them they determined almost everything else:
- Production is a prebuilt, pull-only image. Operators do not build. There is no compile step anywhere in installing a module.
- 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. 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.
The boundary is not a sandbox
Section titled “The boundary is not a sandbox”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.
The URL rule, and its one exception
Section titled “The URL rule, and its one exception”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.
Where the design of record lives
Section titled “Where the design of record lives”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.