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>
9.6 KiB
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, 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 a mod loaded by a closed server's own framework, 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), 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), 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
- The emit path enqueues and returns. Nothing on the game thread touches a socket.
- The queue is bounded and drops the oldest, and something counts the drops.
- One writer thread owns the connection; ordering is therefore intact.
- Reconnect with a bounded backoff; a stale connection's cleanup cannot affect a newer one.
- Inbound lines are marshalled onto the game thread before touching the world, and a handler that throws cannot escape into the engine.
- Every board's current state has exactly one producer, and all of them run on connect.
- Every world read is on the world's thread; the writer sees only plain data.
- 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, and please say where you left the kit and what you did next.