Files
Integration-kit/book/04-game-plugin.md
wtclaude d497a3b09a
All checks were successful
PR Checks / prose (pull_request) Successful in 8s
PR Checks / template (pull_request) Successful in 28s
docs(book): name Oxide, and the question "how many sidecars" answers
Follows the org lead's two corrections on docs#170. The Rust example is an OXIDE
plugin - naming the framework is the difference between a design a reader can
start from and one they have to go and choose for themselves - and the
architecture pairs one sidecar to one game server, on that server's own host.

Chapter 3 gains the general form of that second one, since it is the chapter
where a reader decides what to build: if your game runs as a fleet, "how many
sidecars" is answered by where the loopback boundary is, not by how many
processes you would rather run. Your module holding several clients is the
cheaper end of that trade, and core never learns there is more than one.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 04:16:48 -05:00

187 lines
9.6 KiB
Markdown

# 4. The game-side plugin
The chapter with the least code and the highest stakes. Everything else in this
book fails by showing an operator a broken web page; this part fails by taking the
game down while people are playing it.
If your game already **delivers events** on a surface of its own, you may not need
any of this — see the end of [chapter 3](03-sidecar.md), and read the test there
before deciding, because a channel that answers questions is not the same thing.
Otherwise something has to run inside the game and feed your sidecar, and the
rules below are what keep that something from being the reason the server froze.
**That something does not have to be source you compile.** The worked example
below is an overlay built into a server whose code you have; the dry run for Rust
is an **Oxide plugin**, C# loaded by a closed server's own mod framework and
hooking published events. Every rule in this chapter applies identically to both —
they are properties of being inside a game loop, not of how you got there.
The worked example is `servuo-plugins`, the Ultima Online shard plugin, whose link
layer is one file: `overlay/Scripts/Custom/Bridge/BridgeLink.cs`. It is C# against
a specific game engine and none of that transfers. The threading contract at the
top of it does, entirely.
---
## The one rule: never block the game
A game server is a loop. Whatever thread runs the world is the thread that must
not stop, and every rule in this chapter is a restatement of that.
**Emitting an event must enqueue and return.** It formats nothing expensive, waits
on nothing, and touches no socket. In `BridgeLink.cs`, `Emit` is called from the
game's own thread, appends a line to a queue, signals a waiting writer, and
returns. A sidecar that is slow, wedged, restarting or entirely absent cannot stall
the game, because the game never touches the connection.
The failure this prevents is not hypothetical, and it is not a small one: a socket
write from the world thread against a peer that has stopped reading blocks until
the OS buffer drains. That is a frozen game server, caused by a monitoring
feature, at exactly the moment something else is already wrong.
## The queue is bounded, and it drops the oldest
An unbounded queue in front of an absent consumer is a memory leak with a delay
timer on it. So the queue has a cap, and when it is full **the oldest record is
dropped and counted**.
`Emit` bounds first and enqueues second, so the queue can sit transiently one over
the cap and never grows without limit. `BridgeLink` exposes counters — sent,
dropped, received, connects, write errors, current depth — and those counters are
what an operator debugs from later.
Dropping is correct here, and it is worth being explicit about why: **telemetry is
worth less than the game's memory.** If your sidecar has been unreachable for ten
minutes, the useful thing is the most recent state of the world, not a
ten-minute-old backlog delivered before it. Newest-wins is the honest policy, and
"stall the game rather than lose an event" is never the trade to make.
Where losing events is genuinely unacceptable, the answer is the sidecar's store
([chapter 3](03-sidecar.md)), not a bigger queue inside the game.
## One writer thread owns the socket
A dedicated thread drains the queue and owns the connection. It connects,
reconnects with backoff, and writes.
**A single writer is also what keeps event ordering intact** — with two, the order
events reach the sidecar is the order two threads happened to be scheduled in, and
you find out from a board that says a player logged out before they logged in.
The reconnect loop in `LinkLoop` backs off with a low ceiling — a few seconds,
because a loopback reconnect is cheap and a sidecar restart should cost a few
seconds of buffering rather than half a minute of blindness. Pick your ceiling from
what the connection actually costs, not from a habit borrowed from internet
clients.
One detail there is subtle enough to be worth stealing: `BridgeLink` tags each
connection attempt with an **epoch**, so a reader thread from a previous connection
cannot tear down the connection that replaced it. Joining a thread can time out;
the stale thread's cleanup then runs against whatever is current. If you write a
reconnect loop, write it so a late-arriving cleanup from a dead connection is a
no-op.
## Read the world only on the game's thread
Inbound is the mirror image. A reader thread parses lines off the socket, and then
**hands each one to the game's own thread** to act on — `BridgeLink`'s `Dispatch`
does it by scheduling a zero-delay callback on the game's timer, which is the
engine's supported way in. The reader itself never touches the world's objects.
Two rules fall out and both are absolute:
- **Every read of the world happens on the world's thread.** Game engines are
overwhelmingly single-threaded about their state, and reading a collection while
the loop mutates it is a crash or, worse, a corruption you notice a week later.
- **The writer thread only ever sees plain data.** Format your line — a string, a
buffer, whatever your wire is — on the game's thread while the objects are safe
to read, and hand the finished bytes over. Never hand the writer a live game
object to serialise.
And an error boundary at the seam: a malformed command from the sidecar must never
escape into a game code path. `BridgeLink` wraps the inbound handler and logs
anything it throws, because the alternative is an exception unwinding somewhere in
the engine's main loop.
## Reconnect, and what to send on connect
Your sidecar restarts independently of your game. It comes back with an empty
picture, and it cannot ask the game for one without an inbound path you may not
have built yet.
So **anything the sidecar needs up front is re-sent on every connect, not once at
startup.** In `servuo-plugins` that is an explicit event: the link exposes a
"connected" hook that runs on the game thread, and each feature area subscribes to
it and re-emits its current state — the hello line, the house registry, the guild
and governor boards, the market, the ruleset. A sidecar that has just started is
therefore fully populated within one connection, with no negotiation.
The general form: **for every board your website renders, have exactly one place
that can produce its current state, and call it on connect.** If you cannot name
that place for some piece of state, your sidecar will eventually be missing it and
nobody will know why.
## Events, snapshots, and the state that has neither
You will end up emitting two different kinds of thing, and confusing them is a
design mistake that shows up as a bad page.
**An event** is something that happened, at a time: a player logged in, a house
fell, a trade completed. Events are appended and read back as history.
**A snapshot** is the current state of a subject: this board's rows, this guild's
membership, the published ruleset. Snapshots overwrite; nobody wants the history of
a leaderboard's every intermediate ordering.
Send both, and be clear at the wire about which a message is — your sidecar's store
handles them differently ([chapter 3](03-sidecar.md)), and a snapshot appended as
history is a table that grows forever.
Then there is the state your engine gives you no hook for at all. Player vitals,
decay timers, money supply: nothing fires when they change. `servuo-plugins` polls
those on the game's own thread with repeating timers, in
`overlay/Scripts/Custom/Bridge/BridgeSweeps.cs`, and the comment worth copying is
that this is only acceptable **because the cost was measured**. A full pass of all
three sweeps is well under a millisecond at that shard's scale. Measure yours
before you add a timer to a game loop, and if a sweep is expensive, sample it —
never move it off the game thread.
Two practical notes from that file, both general:
- **Emit a transition, not a level.** The decay sweep keeps the last known level per
house and emits only when one changes, and it takes a silent baseline at startup
so a restart does not re-announce every house's current state as news.
- **Know when your engine suspends timers.** These do not fire during a world save,
so a sweep that would have landed mid-save simply happens a few seconds later.
That is fine for all three — but it is fine because someone checked, not by
default.
## A checklist for the plugin you are about to write
1. The emit path enqueues and returns. Nothing on the game thread touches a socket.
2. The queue is bounded and drops the oldest, and something counts the drops.
3. One writer thread owns the connection; ordering is therefore intact.
4. Reconnect with a bounded backoff; a stale connection's cleanup cannot affect a
newer one.
5. Inbound lines are marshalled onto the game thread before touching the world, and
a handler that throws cannot escape into the engine.
6. Every board's current state has exactly one producer, and all of them run on
connect.
7. Every world read is on the world's thread; the writer sees only plain data.
8. Anything polled has had its cost measured against a realistic world.
If all eight hold, the worst a broken sidecar can do to your game is nothing at
all — which is the entire point of the arrangement.
---
That is the book. The three parts are a module core loads, a sidecar that owns the
game connection and the durable copy of what it said, and a plugin that feeds the
sidecar without ever waiting on it.
If you got this far and built something, the places you got stuck are the most
valuable thing this repo can receive — [tell us][issues], and please say where you
left the kit and what you did next.
[issues]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit/issues