The kit taught, to an audience outside this org, that Rust needs "no game-side plugin to write at all" because it ships RCON. That is overruled: the Rust dry run reaches the game through a MOD - a plugin loaded by the server's own framework, hooking events and dialling out - exactly as the ServUO overlay does (docs#170). Chapter 3's section kept its question and lost its example, which turned out to improve it. The useful test is not "does my game expose a protocol" but "does it DELIVER EVENTS": a remote-control channel is built for an operator typing commands and tells you what you asked about, when you ask, and a website needs what happened whether or not anyone was listening. A channel that answers questions can only be polled, and polling turns "someone left the clan at 14:02" into "the count was different at 14:03". Rust now appears in that section as the counter-example rather than the example, and carries the finding that is actually worth having: its server is a BINARY where ServUO is source you compile, and the three-part shape survives that unchanged. The plugin-dials-out arrangement is not a property of having source access. Chapter 4 said a game with a remote-control protocol may not need any of it, and that its worked example is source you build. Both now say what is true - the rules in that chapter are properties of being inside a game loop, and apply identically to a mod in a closed server. Co-Authored-By: Claude <noreply@anthropic.com>
195 lines
10 KiB
Markdown
195 lines
10 KiB
Markdown
# 3. The sidecar
|
|
|
|
Your module may not open a connection to a game server. Not a game socket, not an
|
|
RCON channel, not a query port, not an engine's admin API. It talks to a
|
|
**sidecar**, and the sidecar talks to the game.
|
|
|
|
That is a rule in the contract ([`MODULE_API.md`][api] §2.7, as of
|
|
`MODULE_API_VERSION` 1.4.0) rather than advice this kit is offering. It is also
|
|
the rule most likely to feel like ceremony when your game already exposes a
|
|
perfectly good remote-control protocol and your module is fifty lines from
|
|
working. This chapter is why it is not.
|
|
|
|
**It is the one rule in that list with no CI behind it.** An outbound socket is
|
|
not statically detectable the way an internal `require` is. So it is enforced by
|
|
review, and by you having read this.
|
|
|
|
---
|
|
|
|
## What a sidecar is
|
|
|
|
A small, separate service that owns the connection to your game, keeps a durable
|
|
copy of what the game said, and exposes an HTTP + WebSocket API that the website's
|
|
backend reads.
|
|
|
|
```
|
|
your game server ──dials out──▶ your sidecar ──HTTP + WS──▶ website core
|
|
│ (your module)
|
|
▼
|
|
its own store
|
|
```
|
|
|
|
Three properties, and each is doing real work.
|
|
|
|
## 1. The game dials out; the sidecar listens
|
|
|
|
The sidecar binds the listener. The game connects **to it**, and the game opens no
|
|
listening port at all.
|
|
|
|
This is the inversion people find surprising and it is the load-bearing one. The
|
|
website is the internet-facing process; your game is not, and must not become
|
|
reachable because a web app knows how to reach it. A module holding the connection
|
|
makes the public web app the thing the game trusts, and puts the game's address
|
|
inside the same process as every request from the internet.
|
|
|
|
In `uo-link`, that listener is `sidecar/src/shard.rs` — `serve` binds a loopback
|
|
address and accepts shard connections forever, handling one at a time and looping
|
|
back to accept the next. The game plugin does the dialling, with its own backoff.
|
|
Loopback, in that deployment, because the sidecar runs on the game host: the only
|
|
socket the game speaks over never leaves the machine.
|
|
|
|
Only the website's backend talks to the sidecar, and it authenticates. `uo-link`'s
|
|
`web.rs` requires a token on every request — accepted as a bearer header, an API-key
|
|
header, or a query parameter, that last one only because browser WebSocket clients
|
|
cannot set handshake headers — and compares it in constant time. Auth is always on;
|
|
there is no unauthenticated mode to accidentally deploy.
|
|
|
|
## 2. Persist before you forward
|
|
|
|
This is the property that makes a sidecar worth having even when your game is
|
|
already remote-controllable, and the one a message-passing diagram never conveys.
|
|
|
|
**The sidecar owns the durable copy.** It writes what the game said into its own
|
|
store, and answers reads from that store — not by round-tripping the game.
|
|
|
|
`uo-link` does this in `sidecar/src/store.rs`: SQLite, holding event history, the
|
|
latest snapshot of every board the site renders, the economy series and the
|
|
published ruleset. `insert_event` is called for every live event as it is
|
|
broadcast; the `upsert_*` functions keep one current row per board; the REST read
|
|
paths query that store.
|
|
|
|
What it buys, concretely:
|
|
|
|
- **A website that is down, restarting or mid-deploy loses nothing.** Events that
|
|
arrive while nothing is listening are still recorded. Without a store they are
|
|
simply gone, and your first deploy of the week is a hole in your data.
|
|
- **A page renders the last thing the game said rather than going blank.** A rules
|
|
page that empties itself because the game restarted is worse than a stale one.
|
|
- **The live feed is allowed to be lossy.** `uo-link`'s WebSocket fan-out drops
|
|
frames for a consumer that has fallen behind and logs that it did — deliberately,
|
|
because durability is the store's job and not the socket's. A feed that instead
|
|
buffered without limit for a slow client would eventually take the sidecar down.
|
|
|
|
That last point is the reasoning to carry into your own design. Once the store is
|
|
authoritative, every other component is allowed to be best-effort, and each of them
|
|
gets simpler. Skip the store and you find yourself trying to make a socket reliable,
|
|
which is the hard version of this problem.
|
|
|
|
A module cannot do any of this from inside the website process. There is nowhere to
|
|
put what arrives while the website is not running, because the website not running
|
|
is exactly the case.
|
|
|
|
## 3. The wire is a versioned contract, not a build dependency
|
|
|
|
Your sidecar and your module ship separately, on different schedules, to hosts you
|
|
do not control. So the wire between them is a compatibility contract with a version
|
|
on it.
|
|
|
|
`uo-link` declares `PROTOCOL_VERSION` in `sidecar/src/main.rs`, stamps
|
|
`X-UOLink-Version` onto every response from `web.rs`, and **refuses a request whose
|
|
declared version does not match** rather than parsing it optimistically. A refusal
|
|
is a clear failure an operator can act on; a mis-parse is a wrong number on a page
|
|
with nobody to tell.
|
|
|
|
Two habits come with that:
|
|
|
|
- **Bump the version in the same change that changes a message shape**, on every
|
|
side that declares it. In this project a protocol bump has three declaration
|
|
sites — the sidecar, the game-side overlay's manifest, and the documented spec —
|
|
and the tooling refuses to pair components that disagree.
|
|
- **Version the *shape*, not the content.** Adding a new event kind that old
|
|
consumers ignore is not a break. Changing what a field means is, even when the
|
|
JSON still parses.
|
|
|
|
## The worked example
|
|
|
|
`uo-link` is a complete implementation of everything above, and it is small enough
|
|
to read:
|
|
|
|
| File | What it owns |
|
|
| --- | --- |
|
|
| `sidecar/src/shard.rs` | The listener the game dials into; one connection at a time, then accept the next. |
|
|
| `sidecar/src/store.rs` | SQLite: event history, per-board snapshots, the series and the ruleset. |
|
|
| `sidecar/src/web.rs` | HTTP + WebSocket for the website, the auth middleware, the version header and the lossy live fan-out. |
|
|
| `sidecar/src/rpc.rs` | Request/reply correlation, so a website read can ask the game a question and match the answer. |
|
|
| `sidecar/src/config.rs` | The config file, including a token generated on first run rather than defaulted. |
|
|
|
|
The protocol it speaks is specified in [`link/PLAN.md`][linkplan] and
|
|
[`link/INTEGRATION.md`][linkint]. Those are normative for that sidecar; your game
|
|
is not Ultima Online and your messages will not be its messages. What transfers is
|
|
the structure — a listener the game dials into, a store written before anything is
|
|
forwarded, a lossy live feed, an authenticated read API with a version on it.
|
|
|
|
## "But my game already speaks a remote-control protocol"
|
|
|
|
Then your sidecar is **thin**, not absent — and be sure the surface you are
|
|
thinking of actually carries what your module needs, because that is where this
|
|
question usually goes wrong.
|
|
|
|
A remote-control channel is built for an operator typing commands: it tells you
|
|
what you asked about, when you ask. What a website needs is what *happened* —
|
|
every kill, every join, every departure, delivered whether or not anyone was
|
|
listening at that moment. Those are different products, and a channel that
|
|
answers the first can only approximate the second by polling it, which turns
|
|
"someone left the clan at 14:02" into "the count was different at 14:03".
|
|
|
|
So the honest test is not *does my game expose a protocol* but **does it deliver
|
|
events**. If it does, your sidecar keeps that connection, its credentials and its
|
|
reconnect loop out of an Express process, keeps a store so the site is not blank
|
|
whenever the game restarts, and presents your module one versioned HTTP + WS
|
|
shape. That is what thin means: less code, the same architecture.
|
|
|
|
**Rust is the worked example, and it is not that case.** The dry run in
|
|
[`rust-dryrun.md`][dryrun] designs a module for it precisely because it shares so
|
|
little with Ultima Online — and its answer is a **mod**: a plugin loaded by the
|
|
server's own mod framework, hooking the game's events and dialling out to a
|
|
sidecar, exactly as the ServUO overlay does. The Rust server is a *binary*, where
|
|
ServUO is source a shard owner compiles, so the way in is a published hook API
|
|
rather than a file you edit. **The three-part shape survives that unchanged**,
|
|
which is the more useful finding: the plugin-dials-out arrangement is not a
|
|
property of having source access.
|
|
|
|
That document reached the game over RCON until 2026-08-19, and before that
|
|
concluded there should be **no sidecar at all**. It carries both corrections,
|
|
dated, rather than having been quietly rewritten — the value of a dry run is the
|
|
record of what it found, including where it was overruled.
|
|
|
|
## Building yours
|
|
|
|
There is no template for a sidecar in this kit; it is your program, in your
|
|
language, and the surface it must expose is the surface your module reads. What to
|
|
settle before writing code:
|
|
|
|
1. **Which direction does the connection go?** The game dials out. If your game
|
|
cannot — if it only accepts connections — then your sidecar is the client to the
|
|
game and the listener for the website, and the rule that stands is the one that
|
|
matters: the address of the game is known to the sidecar and to nothing else.
|
|
2. **What is durable?** Everything a page must still render when the game is down.
|
|
Write it before you forward it.
|
|
3. **What is a snapshot and what is an event?** They are different storage
|
|
problems: an event is appended and read back as history, a board is one current
|
|
row per subject that you overwrite. `uo-link`'s store holds both, and keeping
|
|
them separate is why a restart does not replay a year of events at a page.
|
|
4. **What is the version, and where is it declared?** One place, on every response,
|
|
refused on mismatch.
|
|
5. **How does the website authenticate?** A token, generated rather than defaulted,
|
|
always required.
|
|
|
|
Then chapter 4, if your game needs code inside it — which is the part where getting
|
|
it wrong takes the game down rather than the website.
|
|
|
|
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
|
|
[linkplan]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md
|
|
[linkint]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md
|
|
[dryrun]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/rust-dryrun.md
|