Files
Integration-kit/book/04-game-plugin.md
wtclaude f41ff92c67
All checks were successful
PR Checks / prose (pull_request) Successful in 8s
PR Checks / template (pull_request) Successful in 27s
docs(book): the four chapters — Phase 5 slice 2
The book, written out of the tree slice 1 proved. Four chapters in the order the
work happens: the first module in twenty minutes, the website module, the sidecar,
and the game-side plugin.

Shape, settled with the org lead:

  * template/README.md stays the REFERENCE — it travels with a copied template and
    CI holds it against the tree — and chapter 1 is the narration: what you should
    see after each step, the state your module lands in, and the four ways it fails.
    The chapter links to the checklist rather than restating it.
  * chapters 3 and 4 cite link/ and servuo-plugins/ by FILE AND IDENTIFIER, never by
    line. Those repositories move for their own reasons and checkLinks already
    forbids commit permalinks, so a line number in this book is wrong the moment
    they do. The template stays the only code quoted verbatim.
  * one PR: the outline's status table and the link check are only coherent when the
    whole set lands.

scripts/checkChapterPaths.js is the anti-rot half a machine can answer: every path
a chapter names in backticks must exist. None of those mentions is a markdown link,
so checkLinks never looked at them, and none is code, so nothing else did either —
renaming one template file would have left four chapters quietly pointing at
nothing. Its anchor list is STATED rather than derived from the tree, for the reason
the template's own build guard states it: a list derived from what exists cannot
fail when what exists changes, and an anchor that stops matching is a check that has
silently stopped checking. So each anchor must exist or the check fails. Eleven
tests, every "must not catch" case a span that really appears in the book.

stripFences moved to scripts/lib/markdown.js and both checks use it — shared code,
not a shared description.

CHAPTER 1 WAS RUN, NOT REASONED ABOUT. The template was copied into a real core on
edge, booted against the dev database, and every claim in "what you should see"
checked: the five log lines, /examplegame/status with its injected
<script type="module" src="/modules/examplegame/entry.js">, the chunk served
no-cache while module.json 404s, /api/v1/public/world/status, the capabilities in
/api/v1/public/modules, and the route in the merged /api/docs.json. Then the three
failures the chapter tells a reader to cause on purpose, because a chapter that
predicts the wrong debugging heuristic is worse than one that predicts none:

  * an undeclared prefix  -> stage `register`, "declared public/extra but never
    registered it", routes 404 and absent from /public/modules;
  * a table without the id prefix -> stage `schema`, at LOAD time, before mounting;
  * a throwing onBoot -> after mounting, so the same route answers 503 "Module
    unavailable" rather than vanishing.

All three came out exactly as written, and the messages in the chapter are that
core's own. Two small corrections fell out of the run: the log sample now shows the
real interleaving of core's three lines with the module's two, and the section on
failure adds that a module disappears from /api/v1/public/modules in every failure
case — a check that needs no login.

MODULE_SYSTEM.md 2.11.1 slice 2. Docs half: docs#146.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 13:20:29 -05:00

9.1 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 speaks a remote-control protocol, you may not need any of this — see the end of chapter 3. If it does not, 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.

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

  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, and please say where you left the kit and what you did next.