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.
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
# 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 three handler kinds (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 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`](./bgm-tabletop.md) 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.
|
||||
|
||||
```ts
|
||||
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).
|
||||
|
||||
```ts
|
||||
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.
|
||||
|
||||
```ts
|
||||
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.
|
||||
|
||||
```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 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`:
|
||||
|
||||
```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.
|
||||
|
||||
### 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.
|
||||
Reference in New Issue
Block a user