Files
Integration-kit/book/05-events.md
wtclaude f89044b42e
Some checks failed
PR Checks / prose (pull_request) Successful in 12s
PR Checks / template (pull_request) Failing after 29s
feat(kit): the event contract, taught and built (chapter 5)
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
2026-09-08 18:10:18 -05:00

19 KiB

5. Making your module event-capable

Chapters 1 to 4 got a game onto the platform: a module that reads, a sidecar that stores, a plugin that tells it what happened. Everything in them moves one way — out of the game and onto a page.

This chapter is about the other direction. The event system is core's engine for scheduled, bounded, audited changes to a live game world: an operator writes an event on the website — a phase that announces, a phase that spawns something, a phase that waits for a condition, a phase that cleans up — publishes it, schedules it, and it runs unattended at two in the morning. Your module is what lets any of that touch your game.

It is also the first thing in this book that can do damage. A page that renders wrong is embarrassing. An action that half-ran and was recorded as done is a change to a live world with nothing coming back for it.

Nothing here is normative. EVENTS.md is the design of record and MODULE_API.md is the contract; where this chapter and either of those disagree, they are right and this chapter has a bug. What is here is the ordering, the reasoning, and the four mistakes that are invisible until an outage.


Everything in this chapter is optional

Stated first because it changes how you should read the rest.

A deployment with no module at all still has a working event engine. Core owns verbs of its own — announce something, wait, cue a human to do the in-game part, publish results — and an event composed only of those runs on bare core with zero modules installed. That is not a degraded mode; it is a real product, and for many games it is the whole of what you want.

So each of the four declarations below adds something an author can reach for. Registering none of them costs your deployment a capability, never a boot — the same posture as a module with no onBoot, which still reaches started.

Which means you can stop reading at any section boundary and ship what you have.

The four declarations

api.registerEventBudgets([...])        // dimensions core can COUNT and BOUND
api.registerEventOptionSources([...])  // what a dropdown on the form is FILLED from
api.registerEventLeases([...])         // values a run may BORROW, with a deadline
api.registerEventActions([...])        // verbs a run may PERFORM

Four separate id spaces, each namespaced under your module id. examplegame.beacons as a budget and examplegame.beacon.light as an action are not a collision, and reading them as one would forbid the most natural set of names you will ever write. An action names a VERB, a budget a RESOURCE, a lease a VALUE, an option source a CATALOG.

All four are in the template at template/server/config/eventActions.js, one of each, with the four traps marked where they bite. Read that file beside this chapter.

Build the lease first

If you have time for one thing, build a lease, not an action. This is the kit disagreeing with the obvious priority on purpose.

The obvious thing to build is spawning: an event that puts creatures at a landmark is what a game event looks like. But spawning is a shape one genre happens to have, and it is the harder half — something now exists that did not, and your module owes core a way to take it away again on every terminal path, including the ones where nobody is watching.

A lease is the other shape: a value that already existed, changed for a while, and put back. "Double the gather rate for the weekend." "Turn the night length down until Sunday." "Raise this spawner's population for the invasion." That is the canonical community event in most games, and it is cheaper to make safe, because the value you are replacing already exists and reading it first gives you your baseline for nothing.

The verb is core's, not yours. You declare what can be held and how long; an author puts core.lease in a step naming your lease, a value and a number of minutes, and core reads the baseline, reserves the target, applies the value with a deadline, and restores it at teardown through your own restore(). A lease verb of your own would be that duration bound and that "two events cannot hold one target" check re-implemented once per module — advisory everywhere, and wrong in the first one that forgot it.

api.registerEventLeases([{
  id: 'examplegame.rate.gather',
  label: 'Gather rate',
  type: 'float', min: 0.5, max: 5,
  maxDurationMs: 48 * 60 * 60 * 1000,

  async read()                       { /* the live baseline */ },
  async apply(value, until)          { /* hold it, and send `until` down the wire */ },
  async restore(baseline, { expected }) { /* put it back, or report drift */ },
  async inForce()                    { /* optional — a FOURTH question, see below */ },
}])

Three things about that shape are worth more than their size.

until goes down the wire and the far end honours it without being asked again. Core's copy of the deadline is for the console; the game's copy is the fail-safe. A module that passes until and then relies on core coming back to restore has built a lease that outlives an outage — which is the one thing a lease exists to prevent. If the website is never heard from again, the value must still come back.

restore() reports drift rather than overwriting it. expected is what core believes is applied. If the live value differs, somebody moved it by hand during your event, and answering { ok: true, drifted: true, value } lands the row as drifted with the current value beside it. Silently restoring over a human's edit is the bug this exists to prevent.

inForce() is a fourth question, not a fourth spelling of read(). It asks "does the game side still have any record of this hold?", and none of the other three answers it. A value that DIFFERS from what the run applied is drift, which restore() reports; a reconcile that inferred absence from a changed value would take the row out and tell an operator the lease vanished rather than that somebody moved it. Optional — and { ok: true, held: false } is the only thing that takes a lease's ledger row out. A throw, a refusal, or no inForce() at all leaves the row alone.

Only advertise a lease you have verified takes effect. A value your game reads once at start-up and caches will apply cleanly, read back cleanly, and do nothing at all. Core cannot catch that and neither can review — it is a capability that lies. Apply it, observe it in the running game, restore it. Per key, as a test. The UO module surveyed 156 config reads in its game and found roughly eight that were live; the rest were cached at boot and would all have lied.

Actions, and what "owning" something means

An action is a verb an author puts in a step. What it makes, the run OWNS until teardown.

api.registerEventActions([{
  id: 'examplegame.beacon.light',
  label: 'Light beacons',
  risk: 'change',            // notify | inspect | change | irreversible
  reversible: 'ledger',      // none | self | ledger | override
  version: 1,
  budgetMs: 15000,
  cost: (p) => ({ 'examplegame.beacons': p.count }),
  params: [ /* every one carries an `example` */ ],

  async perform({ runId, stepId, idempotencyKey, scope, params, actor, verify }) {},
  async revert({ runId, resources, idempotencyKey }) {},    // required iff 'ledger'
  async reconcile({ runId, resources }) {},                 // optional
}])

reversible: 'ledger' is a promise. It says core may record what you made and come back later to have it undone, and it makes revert required. Core's cleanup is derived, not authored: there is no on_teardown field and no cleanup phase in a spec, because an operator cannot be relied on to write the undo and an aborted run never reaches the phase they wrote it in. Cleanup is one sweep over the ledger and it runs on every terminal path — completion, cancellation and abort alike. Your only job is to answer revert correctly, however many times you are asked.

verify: true must change nothing and must answer honestly. It is the dry run, and it rides the same dispatcher a real run uses — because a dry run down a second code path is a dry run of the second path. Validate everything you can reach without writing, then stop. Answering { ok: true } unconditionally makes the dry run worthless in the one situation it exists for.

example is required on every param, optional ones included. It is the authoring form's placeholder. It is one word at declaration time and it is unreconstructable afterwards by anybody who did not write the action.

A source on a param makes it a dropdown, filled by an option source you (or another module) registered. A source that refuses degrades its field to free text with a warning and never blocks the form — so resolve from live data and return [] on failure, rather than defending with a hardcoded list that will be wrong.


The four things that are invisible until an outage

Everything above is ordinary. These four are the ones that look like they are working, in every test you write and every demo you give, right up until the day something is down.

1. The failure default is a retry, and budgetMs is what makes the other half reachable

No shape a failure can take reads as success. A rejected promise, a throw, a budget timeout, a non-object and a missing ok are all { ok: false, retry: true }. retry is opted OUT of: a module that means "this will never work" must say retry: false.

That direction is deliberate, and it is registerTeamProvider's default inverted. A Team provider that refuses leaves core showing what it had, because staleness is cheap. An action that half-ran and was recorded as done is a world change nothing will ever come back for.

Now the part that is easy to miss. Core's dispatcher enforces budgetMs, and when the budget expires it classifies the failure as retry, unconditionally, without asking you — it cannot ask, your action is still awaiting a socket.

So if your transport's timeout is longer than budgetMs, your own retry: false is unreachable code. Core's default budgetMs is 10 seconds. If your sidecar client waits 12, core's deadline fires first on every slow game and the step is retried no matter what your envelope says. The first module this project shipped had exactly that pairing, and its one deliberately un-retryable verb was retried anyway for a whole phase.

The rule generalises past that one pairing: an action is the near end of a call with a far end, and the near end has to outlive it. Derive one constant from the other rather than typing both, and assert the inequality in a test — the template does both, because a number typed twice drifts the first time somebody tunes the client and does not think to look at the other file.

The reason a refusal gives goes in error. Core reads exactly ok, retry and error off a failure envelope; a message under any other name is dropped in silence and the operator sees "<action id> refused". Writing this chapter's template is how that was found — its first draft used detail, and every refusal it produced was anonymous.

2. Pass the idempotency key through, and put it on a command rather than a question

Core hands perform() an idempotencyKey derived from the step's identity — never from the attempt number — so every retry carries the same one. The far end, which is the only end that can tell a retry from a repeat, executes a key at most once and answers a repeat with the ORIGINAL reply rather than running it again.

Pass it through unchanged. A module that invents its own key here, or drops it, has an action that cannot be retried safely, and the cost of that is not a failed step: it is a second set of everything on a socket hiccup. It looks correct in every test you will write, because in every test the first attempt succeeds.

It is also what a lost acknowledgement is recovered from. Without a key, a command that arrived, ran, and whose reply was lost is indistinguishable from one that never arrived — so the only safe policy is never to retry, and a game restarting mid-run writes the step off. With one, the retry collects the answer the first attempt never delivered.

And it belongs on a command, never on a question. This is the correction writing the template produced, and it is quiet and total: an at-most-once store answers a key it has already seen with the first reply, forever. So a read that carries a key returns the first read's value on every subsequent call — the lease applied correctly, the game changed correctly, and the module could no longer see any of it. read() reported the pre-run baseline and inForce() said nothing was held. The template splits its client into ask() and send() for exactly this reason.

The rule for which commands need a key is narrower than "all of them", too. A key is for a write whose repetition would be a second EFFECT — creating, granting, announcing. A write that SETS a value to X is idempotent by its own nature: doing it twice is doing it once, and a key would only pin its reply.

Build the store on the far end, and persist it. A store in your module answers nothing, because the case that matters is the one where the command arrived and ran. See chapter 4 for the game-side half.

3. Core records a resource BEFORE it is confirmed

This is one line in EVENTS.md §D and it decides the whole shape of your revert.

Core writes a placeholder into its ledger, keyed by the step's idempotency key, before dispatching — so a dispatch whose answer never came back is still something cleanup can act on. Your resources are the refs core did not know until the answer arrived, filled in afterwards.

Two consequences, and both are about what revert must tolerate:

Reverting something that does not exist is a SUCCESS. Cleanup will ask you about rows for things that may never have existed. You must never have to tell "I removed it" from "it was not there" — and you could not, because your game cannot either. Answer { ok: true }. This is also what a game with a monthly wipe needs, where every ledgered resource is invalidated at once and "gone, and that is fine" is the only useful answer.

You will be called with NO resources and only a key. That is the lost-answer case stated exactly: core knows a dispatch went out under this key and never learned what it made. A module that can undo by key answers honestly. One that cannot answers { ok: false }, and the row stays visible to an operator — which is the correct outcome, not a silent one. Answering { ok: true } to a question you cannot answer is how something burns in a live world forever with core's ledger reporting it cleaned up.

revert must also be idempotent, because core may ask more than once.

reconcile is optional where revert is required, and the asymmetry is the design. A module that cannot say what the game still has is not broken — core keeps believing its own ledger, which is the behaviour before any of this existed. One that created something and cannot undo it has made a promise core has no way to keep.

And when you do answer: anything that is not an explicit { ok: true, inForce: [...] } leaves the ledger alone. "I do not know" is never read as "it is gone". A resource you report missing becomes orphaned rather than reverted, because nobody asked for it to go.

You say WHEN to reconcile, because core cannot. Core has no concept of the game being up — it sees { ok: false, retry: true } and cannot tell a wedged sidecar from a game that rebooted and lost everything an event made. So it asks once, at its own boot, and otherwise waits to be told. ctx.events.reconcile() is being told, and the thing that triggers it is your own watch on a boot id changing — which is also how you tell a game restart from a sidecar reconnect. They are not the same event; the second loses nothing.

4. Under-declaring cost turns every cap into a lie

cost(params) says what one invocation consumes. An operator sets caps per dimension, and core refuses a step that would exceed one.

Core prices cost before dispatch and never reconciles it against the resources that come back. It cannot — it does not know what a beacon is. So an action that returns { 'examplegame.beacons': 1 } while lighting twelve turns an operator's cap of 30 into a cap of 360, the meter on the run console agrees with the lie, and nothing anywhere goes red. The first symptom is a world with an order of magnitude more in it than anyone authorised.

Count what you will actually make, from the params you were given, every time. If you cannot know until the answer comes back, declare the maximum: a spend that is too high refuses an event that would have fit, which an author can see and argue with; one that is too low cannot be seen at all.

Two smaller rules ride with it:

  • You cannot spend a dimension no module declared. A cost() naming an unregistered one is refused at save, at the dry run and at dispatch, with its own refusal code — because the fix is a module's declaration and not a deployment's cap.
  • Declaring a dimension is not the same as bounding it. A declared dimension with no operator cap is counted and unbounded, which is useful on its own: the run console then shows an author what their event actually spent.

What core owns that you might think is yours

Four things a second module's author reaches for and should not.

You might build Core already owns it Because
A myGame.lease verb core.lease the duration bound and the two-events-one-target check belong in one place, or they are advisory everywhere
A cleanup phase, or on_teardown the ledger sweep an aborted run never reaches the phase somebody wrote the undo in
Deciding who is told about your event rules and audiences you declare what CAN happen; core decides who is told (chapter 2)
A second write path for participants the participants envelope member a second door into a run core is mid-tick on is a second thing that can race the step claim

What to build, in order

  1. Nothing. Confirm an event composed of core's own verbs runs on your deployment. If it does, the engine is working and everything below is additive.
  2. One budget dimension, declared and uncapped. Costs nothing and makes the next step legible.
  3. One lease, verified live — apply, observe in the running game, restore. This is the primitive that travels, and for many games it is the whole feature.
  4. One option source, so the authoring form stops asking operators to type identifiers from memory.
  5. One action that ledgers, with revert and the four traps above. This is where the work is, and where the damage is.
  6. reconcile, and the boot-id watch that calls ctx.events.reconcile(). Last, because it is the only one whose absence is merely a lower standard rather than a broken promise.

Then read your own revert again, and ask what it answers when the game is down.