Files
tts-workshop/docs/bgm-engine.md
T
hypercross 91cd3a16d7 docs: add bgm-engine message layer design
Define the message/queue/tick model, the three handler kinds (trigger,
orchestrator, command host), and the @tts/engine package split. Cross-link
from bgm-commands and bgm-tabletop, and add the engine to the architecture
package table.
2026-08-10 18:26:19 +08:00

9.2 KiB

bgm-engine

The message layer that drives bgm board games, built into @tts/engine. 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 three handler kinds (who reacts). Command execution — the async lifecycle, run contexts, and tap interaction — is specified in 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 orchestrator 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, Handler, Trigger, Orchestrator, and the CommandHost interface (how a command handler registers with the bus).

@tts/tabletop is one consumer of that contract: it implements the CommandHost with 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 host interface. 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 may have a registered handler (a command implementation); if it does, the runtime runs it. Either way, every handler observes it.

interface Message {
  type: string;       // 'tap' | 'move' | 'focus' | 'move:done' | ...
  data?: unknown;     // payload, command-specific
  id?: string;        // optional identity, for matching and dedup
}

A message is identified by type (and optionally id), 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 (move:done), 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

An orchestrator suspends on await ctx.wait({ type }) 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).

interface TapMessage extends Message {
  type: 'tap';
  data: TapEvent; // part, position, trigger
}

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 type:done message.

interface CommandMessage extends Message {
  type: 'move' | 'focus' | 'caption' | 'highlight' | 'enableSurface' | 'run' | ...;
  data: unknown; // command args
}

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.

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 host code (built-in) 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.

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.

// main.ts
export const orchestrators = {
  intro: async (ctx) => {
    await ctx.focus({ path: '/deck' });
    await ctx.caption({ text: 'Draw a tile' });
    const tap = await ctx.wait({ type: 'tap', part: 'carcassonne:tile#a' });
    await ctx.move({ part: tap.part, to: '/grid/5/5' });
  },
};
  • An orchestrator is a long-running command. It is a Command whose execute awaits events instead of resolving immediately. It inherits the run-context machinery — supersede groups, cancellation, tap subscription — for free. No new lifecycle.

  • 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 orchestrators to the engine. The engine never imports user code.

  • Export shape. export const orchestrators = { intro, scoring } keys orchestrators by type#id like everything else, so they're addressable and collision-checked the same way. A default export is the folder's primary orchestrator.

  • Trigger control lives here. The orchestrator toggles triggers at runtime by their type#id:

    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.

command host — atomic execution

The command host is the built-in handler that runs a command message. It is the interface @tts/engine defines and @tts/tabletop implements: it starts a run, tracks its status, cancels it when superseded, and emits the type:done result message (see bgm-commands.md). Commands are the single mutation path — the only way state changes. Triggers and orchestrators never mutate state directly; they emit command messages, and the host executes them.

5. 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

  • Loop protection. A trigger that emits a command whose completion message it also matches → infinite loop. Guard with "don't re-trigger on your own emitted message" or a depth cap.
  • Command-completion messages. Orchestrators' await and triggers' match both depend on commands emitting a result message (move:done, focus:done). This is a small addition to the CommandRun.done lifecycle in bgm-commands.md.
  • 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 orchestrators to the engine.