# bgm-engine The message layer that drives [bgm](./bgm-format.md) board games, built into [`@tts/engine`](./architecture.md). It unifies the two halves of scripted interaction — *declaring* what should happen and *executing* it — into a single reactive loop: **messages** flow through a **queue**, and **handlers** react to them. This doc covers the message model (what flows), the queue and its tick (how it flows), and the handlers (who reacts). Command *execution* — the async lifecycle, run contexts, and tap interaction — is specified in [`bgm-commands.md`](./bgm-commands.md); this doc is the layer above it. ## package split The engine is a **pure** package: the message bus, queue, tick, trigger registry, and the handler runner. It has no r3f, no React, and no store, so it is node-testable in isolation (mirroring `@tts/extract`'s isomorphic, zero-dep style). It defines the contract — `Message`, the handler registry, `Trigger`, `Orchestrator`, and `RunContext`. [`@tts/tabletop`](./bgm-tabletop.md) is one consumer of that contract: it registers the built-in commands (`move`, `focus`, `caption`, `enableSurface`, ...) that mutate the tabletop store and drive the render layer. The engine never imports tabletop; tabletop depends on the engine for the message types and the handler registry. A headless sim or bot harness can consume the engine without the render layer. ## 1. messages A **message** is the unit of communication. It is both an *event* (something happened) and an *intent* (something should happen) — the two are the same thing. A message is dispatched to the handlers registered for its `type`; a handler may emit new messages in response. Messages are a **discriminated union** on `type`. The engine defines the generic shapes; the host's concrete union extends them with its own command types. ```ts interface TapMessage { type: 'tap'; data: TapEvent; // part, position, trigger } interface CommandMessage { type: Name; data: Args; } ``` A message is identified by `type`, matching the format's `type#id` convention. A `move` message both *runs* the move command and is *observable* as an event; the command's completion is itself a message, which is what triggers match and orchestrators await. The loop is just: **message → handler → message**. Handlers consume messages and emit new ones; the queue serializes them. ## 2. the queue and ticking Messages are not processed inline. They are **enqueued** and handled on the next **tick**. This kills reentrancy (a handler cannot cause unbounded recursion), gives a natural debounce, and makes the whole system a deterministic frame. ### tick contract The engine is pure — it has no render loop and must stay node-testable. It exposes `tick()`, and the host calls it: - In `@tts/tabletop`, a `useFrame` drives `tick()`. - In tests, `tick()` is called manually. The engine never assumes a render loop. ### drain semantics - **Snapshot-and-drain.** At `tick()`, snapshot the queue and process it. Messages emitted *during* the drain go to the *next* tick. This guarantees no reentrancy within a drain and makes ordering deterministic. - **FIFO within a tick.** Simple and predictable. - **One tick drains the whole snapshot** (not one message per tick), so a burst of messages all resolve in one frame. ### awaiting A handler suspends on `await ctx.wait(pred)` and resumes when a matching message is processed during a drain. Its own emissions go to the next tick, so it cannot re-enter itself. ## 3. message types ### interaction messages Interaction is the player's input, reported to the engine as messages. Only tap interaction is supported (see `bgm-commands.md` §4). ```ts interface TapMessage { type: 'tap'; data: TapEvent; } ``` A tap on a part is reported with the nearest trigger point (or `null` on a miss). The handler decides how to react — resolve, reject with a "wrong spot" shake, or ignore. The runtime stays dumb; the handler owns the UX. ### command messages A command message names a command to run. Its handler is the command implementation; its completion is emitted as a result message. A command's result is a **discriminated union on the type suffix**, carrying the terminal state: ```ts type CommandResult = | { type: `${Name}:done`; data: R } | { type: `${Name}:cancel` } | { type: `${Name}:error`; error: Error }; // e.g. move:done { data: MoveResult } | move:cancel | move:error ``` The command-id-as-key convention means a message both *is* the intent and *observes* the result. `move:done`, `focus:done`, etc. are the messages that triggers match and orchestrators await. A cancelled command emits `:cancel`, an errored one `:error` — a trigger matching `move:done` does not fire on a cancel. ## 4. handlers There are three kinds of handler. All three consume messages and emit messages; they differ in how they're declared and how they run. | Handler | Declared | Runs | Purpose | | --- | --- | --- | --- | | **Trigger** | data (yaml) | synchronously on match | declarative reactive glue | | **Orchestrator** | code (`main.ts`) | async, awaits | imperative flow | | **Command** | code (built-in) | async, on its message | atomic execution | ### triggers — declarative reactive glue A trigger matches a message by `type` and named params, and emits messages in response. It is declared as data, keyed by `role+type+id` like other defs, and collision-checked the same way. ```yaml role: trigger type: tap id: draw match: part: carcassonne:tile#a trigger: draw emit: - move: { part: carcassonne:tile#a, to: /grid/5/5 } - focus: { path: /grid/5/5 } ``` - `type` selects the message kind; `match` binds named params from the payload (like a route's candidates). - `emit` uses the command-id-as-key convention. - Multiple triggers can match the same message — both fire, which is usually what you want. - A trigger is a **pre-registered handler**: it's a message consumer that emits commands. An orchestrator can do the same thing imperatively with `ctx.on(...)`. ### orchestrators — imperative async flow An orchestrator is the code counterpart to a trigger: an async function that emits messages and awaits matching ones. It is a proper TS module, declared per folder as `main.ts` — unique per folder like `package.yaml`. ```ts // main.ts export default async function main(ctx: RunContext): Promise { await ctx.focus({ path: '/deck' }); await ctx.caption({ text: 'Draw a tile' }); const tap = await ctx.wait((m) => m.type === 'tap'); await ctx.move({ part: tap.data.part, to: '/grid/5/5' }); } ``` - **`main.ts` is executable code, loaded by the host, not the engine.** The engine defines the contract (the orchestrator type and runner); the host dynamically imports `main.ts` and hands the exported orchestrator to the engine. The engine never imports user code. - **A default export async function.** `main.ts` exports a single async function as its default export, taking the `RunContext`. It is the folder's orchestrator. - **Trigger control lives here.** The orchestrator toggles triggers at runtime by their `type#id`: ```ts ctx.enableTrigger('tap', 'draw'); ctx.disableTrigger('tap', 'draw'); ``` Declaration is data; activation is code. The orchestrator owns game-flow logic ("no more placements this turn" → disable the trigger), while the trigger stays a dumb declarative mapping. ### commands — atomic execution A command is an async function, the same shape as an orchestrator. It takes a `RunContext` (with its `args`), returns its result, and throws on error. The engine wraps it: it builds the context from the message, runs the function, and emits the result message — `:done` on resolve, `:cancel` on abort, `:error` on throw. ```ts type Command = (ctx: RunContext & { args: Args }) => Promise; ``` Commands are the **single mutation path** — the only way state changes. Triggers and orchestrators never mutate state directly; they emit command messages, and the command handlers execute them. ## 5. run context Every handler runs against a `RunContext`, the handle to everything it can affect and the unit of cancellation. ```ts interface RunContext { signal: AbortSignal; // cancellation: superseded, skipped, surface disabled emit(msg: Message): void; wait(pred: (m: Message) => boolean): Promise; // rejects on abort enableTrigger(type: string, id?: string): void; disableTrigger(type: string, id?: string): void; } ``` - **Cancellation** is an `AbortSignal`. A superseded command or a disabled surface aborts the signal; a `wait` rejects on abort, and the command's `:cancel` result is emitted. - **Errors** are thrown. A command that throws emits `:error`; an orchestrator that throws surfaces loudly. - Commands and orchestrators are the same shape: an async function taking the context. An orchestrator is a command that returns `void` and is never awaited by a parent. ## 6. solo-only This design is **solo-only** — no multiplayer. Other players either don't exist or are automated with an automata. An automata is just another message consumer that emits commands: a stateful trigger or orchestrator. The engine doesn't care whether a `tap` message came from a human or a bot decision — same queue, same handlers. Solo-only simplifies the design: no network, no sync, no authoritative-server concerns. "Other players" are just more message producers. ## Open decisions - **`main.ts` loading.** The host dynamically imports `main.ts`; the exact loading boundary (Vite dynamic import, error handling, HMR) is deferred to implementation. The engine defines the orchestrator type; the host loads the module and hands the exported orchestrator to the engine.