Files
runicgateway.com/src/content/docs/docs/architecture/the-bridge.mdx
wtclaude d89ce06bb8
All checks were successful
PR checks / checks (pull_request) Successful in 1m13s
docs(builder): phase 8 — modules, architecture and reference
Twenty pages completing the tree section 10 planned: Modules (8), Architecture
(5) and Reference (7). Four decisions, D38-D41, recorded in PLAN.md section 10.

D39 is the one that shaped the phase. Section 1 forbids re-specifying a
contract, and a Reference section is exactly where that rule is most tempting to
break, so the line is drawn at names: every environment variable, config key,
installer command, visibility rung and canonical document is listed with one
terse line saying what it is FOR, while shapes, semantics and every "why" stay
in the canonical document.

That is only safe because the names are checked. checkReference.mjs compares six
enumerations against the repositories that own them, over the Gitea API, as set
comparisons in BOTH directions -- and the second direction is the one that earns
its keep, because a reference page does not usually rot by describing something
that vanished, it rots by quietly not mentioning what was added since.

The check went green on its first run, which is the least trustworthy possible
outcome, so it was verified by breaking it: seven mutations, all caught. The one
worth keeping is the visibility ladder REORDERED with its membership unchanged
-- it is a security boundary, and a set comparison alone would have passed it.

D41 turns plannedSidebar from a checklist into a checked invariant, and finding
out why was the phase's first defect: it had already drifted, because phase 7
added the Content page under D37 and never updated the list. Nothing failed,
because nothing read it. checkSidebar.mjs now asserts the two trees agree on
groups, labels and order -- order because the order of Getting started IS the
installation path.

Two more things the writing found. PLAN.md's page count was wrong and had been
since section 10 was written ("roughly 38, 37 planned" for a tree of forty).
And module.json's `mounts` and the SPA's paths are different mechanisms that no
single document stated plainly -- module-uo declares admin: ["/shard",
"/uo-link"] while its screen lives at /admin/uo/link, because API routes are
deliberately NOT namespaced while SPA routes are. That is precisely the
distinction the installer got wrong in v0.1.0, and it now has a named home.

D40: the docs link to /architecture/'s drawn diagrams rather than importing
them. Those components carry marketing chrome and depend on diagram.css, which
Starlight does not load; the docs use text diagrams, which paste into an issue.

npm run verify green: 40 pages across 5 groups agree with plannedSidebar, 2390
internal links resolve, 123 repository links point at a branch, 19 facts, 59
quickstart checks, 22 reference enumerations, astro check 0 errors, 36 tests.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 12:17:31 -05:00

132 lines
5.9 KiB
Plaintext

---
title: The bridge
description: How a game server reaches the website without ever being reachable itself — the sidecar, the loopback socket, and the rules that keep the game running.
---
import { Aside } from '@astrojs/starlight/components';
The bridge exists to answer one question safely: **how does a private game server's live
state reach a public website?**
The answer is a **sidecar** — a small service that owns the connection to the game and the
durable copy of what the game said. It is not optional, and the reasons are worth
understanding before you build one for another game.
## The shape
```
ServUO shard ──dials out──▶ uo-link sidecar ──HTTP + WS──▶ website
(C# plugin) 127.0.0.1:7788 (Rust) bearer + version (module)
newline JSON
▲ │
└──────── the game opens NO port ──────┘
```
Three properties fall out of that diagram, and each is a rule rather than an
implementation detail.
## 1. The game dials out
**The sidecar is the listener. The game connects to it.** The shard opens no port at all,
and nothing on the internet can reach it even in principle.
This inverts the intuitive design — you would expect the thing with the data to serve it —
and the inversion is the whole security argument. Only the sidecar is exposed, and only the
website's backend talks to the sidecar.
The transport is deliberately boring: **newline-delimited JSON, one object per line**, over
loopback TCP.
## 2. A wedged sidecar must never stall the game
This is the constraint the plugin is built around.
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.
<Aside type="caution" title="Dropping events beats pausing the game">
If the queue fills, the oldest events are discarded. That is the correct trade: a game
server that stutters because a logging sidecar is slow is a broken game server, and no
website feature is worth a lag spike.
Design your own plugin the same way. The game thread must never block on I/O — not on a
socket, not on a lock held by a writer, not on a DNS lookup.
</Aside>
Inbound commands get the mirror rule: **every inbound handler marshals to the core thread
before touching world state.**
## 3. The sidecar persists before it forwards
The sidecar owns a durable store. It is not a proxy that translates and forgets — if the
website is down, the game's events are still recorded, and a reconnecting website catches
up.
This is what "a *thin* sidecar" means in the Integration Kit: thin in *logic*, not thin in
responsibility. The sidecar is a **dumb forwarder** — it makes no access-control decisions
and holds no policy. Access control and the admin-toggleable visibility scope live on the
**website**, where an administrator can see and change them.
## Two ways in
**Live events** arrive over an outbound **WebSocket** and are routed by the module's ingest
dispatcher. Kinds are handled differently by nature: state-changing kinds update tables,
notable kinds append to an events log, and high-frequency kinds only update state rather
than accumulating history.
**Point-in-time reads and commands** go over **REST**, through a client that never throws.
Every call carries `Authorization: Bearer <token>` and an `X-UOLink-Version` header. **A
protocol mismatch fails fast with `409`** rather than being mis-parsed — see [Protocol
versions](/docs/architecture/protocol-versions/).
## What the shard can say
The catalog spans sessions and identity, character state, economy and commerce, housing and
IDOC, combat and PvP, progression, cheat detection and staff audit, and server lifecycle.
A representative line looks like:
```json
{"t":1752,"kind":"vendor.sale",
"buyer":{"serial":"0x1A2B","acct":"PerryAdimn"},
"owner":{"serial":"0x33C1","acct":"Feng"},
"item":{"serial":"0x4001A2","type":"Longsword","amount":1},
"price":75000,"commission":3750}
```
The full catalog is [Event catalog](/docs/reference/event-catalog/).
## Two design details worth stealing
**`server.hello` is per-connection, not per-boot.** The sidecar restarts independently of
the game, so anything it needs up front must be re-sent on **every** connect. An earlier
draft emitted a "started" event once at boot; a sidecar that came up second never received
it and had no idea which shard it was attached to.
It carries a `bootId` — a GUID generated at server start, stable across sidecar reconnects
and changed on every game restart. That is how the sidecar tells *"I reconnected"* (keep
cached state) from *"the game restarted"* (discard it).
**Rosters are sets, not signatures.** Guild membership is compared as a set rather than
folded into a checksum, because a sum can collide: one member joining and another leaving
between two sweeps offset each other, and the guild reads as unchanged. A set can also be
*differenced*, which is what makes per-member leave events possible for a game that raises
no event for leaving.
On a guild's **first** sweep there is no prior set, so nothing is reported as leaving — an
unknown roster becoming known is not 155 people leaving at once.
## Building one for another game
The bridge is not UO-specific in shape, only in vocabulary. Chapters 3 and 4 of [the
Integration Kit](/docs/modules/the-integration-kit/) cover the sidecar and the game-side
plugin, and they are the two parts where the mistakes are most expensive.
## Canonical documents
[`link/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md)
§5 and §7 are the data catalog and the wire protocol;
[`link/INTEGRATION.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md)
is the integration guide. Both are normative; this page is not.