The fifth chapter, and the template code it teaches out of. Events is the first
thing in the book that goes the other way — chapters 1-4 move data out of the
game and onto a page; an event changes a live world on a schedule, unattended.
**Chapter 5** covers the four declarations (budgets, option sources, leases,
actions), leads with the lease because EVENTS.md §H is right that it is the
primitive that travels and the spawn is the special case, and gives one section
each to the four things that are invisible until an outage: the envelope's
failure default, the idempotency passthrough, recording a resource before
confirming it, and under-declaring `cost`.
**Chapters 3 and 4 gain one section each** for the command plane, because
without them chapter 5 teaches a module to send an idempotency key to a sidecar
the book never told anyone to build a command path in. Both say at the top that
they are skippable until you want chapter 5.
**The template ships one of each declaration**, with `server/sidecarClient.js`
as the near end — a real timeout, a real key passthrough, a simulated transport
in one function marked for replacement. That file is named for the filename
`noGameConnection.test.js` already anticipated, so the test stays green now and
fires correctly the moment `deliver()` becomes a request.
Two things writing it found, both now in the chapter and beside the code:
* **An idempotency key belongs on a command, never on a question.** The first
draft keyed every call including the reads; an at-most-once store then
answers every future read with the first one's reply, forever. The lease
applied correctly and the module could no longer see it. Hence `ask` and
`send` as two functions.
* **A refusal's reason goes in `error`; core reads no other name.** The first
draft used `detail`, on the strength of the one place EVENTS.md §H mentions
it, and every refusal it produced was anonymous on the run console.
Proved by running the template's real declarations through core's real registry
at `edge` (all four accepted) and its real envelopes through the real
`events/dispatch.js` classifier.
**CI is RED on `checkCoreApi` and that is the mechanism working.** The template
now declares `coreApi: ^1.10.0` and `ci/core-ref.json` pins the engagement
cutover, where `main` is still 1.9.0. Equality is the check, a bump is meant to
turn this repo red until someone re-reads the chapters, and the pin move rides
in the events cutover (EVENTS_PLAN.md P16) as its own commit. Do not "fix" it.
Refs EVENTS_PLAN.md Phase 15, EVENTS.md §F, MODULE_API.md 1.10.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
13 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 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), 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.
A command that changes the world runs at most once
Skip this until you want chapter 5. Everything above assumes an inbound line either asks a question or is a one-off an operator typed. An event is neither: it is unattended, it is retried, and what it does is permanent.
Three obligations, and they all live on this side of the wire because this is the side that has the world.
Keep a key store, and persist it. Every command an event sends carries an idempotency key — a function of the step's identity, never of the attempt, so a retry carries the one the first attempt did. Before executing, look the key up:
- not seen — execute, then record the key with the reply you are about to send;
- seen and finished — send that stored reply back, unchanged. Do not re-run;
- seen and still running — answer "busy". It is transient by construction, and the caller will retry; running it concurrently with itself is the failure.
The stored reply matters as much as the guard. A repeat that re-ran and returned a new serial would be two things in the world and one in the website's ledger, which is the exact failure the key exists to prevent, arrived at by a longer route.
Persist it in the world save, not in memory, if what the command creates survives a restart. The case this whole mechanism exists for is a game restarting mid-event, and a key store that dies with the process is a store that is empty in precisely that case.
Own what an event made, and expire what it borrowed.
An event-created thing has to be findable again later, because the website will ask
you to remove it after the event and may ask more than once. That means a registry
— a persisted map from the website's reference to the object — and it means
remove is idempotent: removing something that is not there is a success. The
website records a resource before it is confirmed, so it will ask you about
things that may never have existed, and neither end can tell the difference.
A borrowed value is the mirror. It arrives with a duration, and you arm a timer that puts the baseline back when it expires. If the borrowed value lives in the save file, persist the hold and re-arm the timer at load — a restart preserves the change and destroys only the thing that would have undone it. Restore with a compare-and-set against what you applied: if a staff member has moved it by hand since, report that rather than overwriting them.
The through-line: the game enforces the expiry, not the website. If the website is never heard from again, every borrowed value still comes back on its own.
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.
Three more, and only if you took commands (chapter 5):
- A command's idempotency key is looked up before it is executed, and a repeat is answered with the stored reply rather than re-run.
- What an event made is in a persisted registry, and removing something absent is a success.
- A borrowed value's expiry is armed by this side, re-armed at load, and restored with a compare-and-set.
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.
Chapter 5 is the optional fifth part: what to declare if you want the website to be able to change your world on a schedule, and the four mistakes that make that unsafe.
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.