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>
187 lines
9.6 KiB
Markdown
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 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](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
|