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.
5.4 KiB
bgm-commands
Command execution for bgm board games, built into
@tts/tabletop. A command is a unit of scripted
interaction — focus the camera, wait for a tap, move a part, show a caption —
that runs against the tabletop state store and render layer.
This doc covers command execution: the async lifecycle, run contexts, and
tap interaction. The message layer above this — how commands are declared
and fired (triggers, orchestrators, the message queue) — is specified in
bgm-engine.md. The command host is the @tts/tabletop
implementation of the engine's CommandHost contract.
1. async commands
A command is an async function that returns a result. Every command ends in one of three states:
ok— completed normally.cancel— interrupted (a newer command superseded it, the user skipped, the surface was disabled). Not a failure.error— genuinely failed (asset missing, bad path, a thrown exception).
cancel is distinct from error: a superseded or skipped command stops
cleanly, while a broken command surfaces loudly. The runtime treats them
differently — a script that is superseded unwinds without alarming the player,
but an error is reported.
type CommandResult =
| { status: 'ok' }
| { status: 'cancel' }
| { status: 'error'; error: Error };
2. run contexts
Each command invocation creates its own run context: the unit of cancellation and the carrier of command-specific state.
interface CommandRun {
id: string;
command: Command;
status: 'running' | 'ok' | 'cancel' | 'error';
data: unknown; // command-specific state, e.g. a pending tap target
cancel(): void;
done: Promise<CommandResult>;
}
A command owns its own state and its own waiting; the runtime only orchestrates. Its job is to start a run, track its status, cancel it when superseded, and react to its terminal state. This keeps commands self-contained and testable in isolation.
3. fire-and-forget vs self-managed waiting
Commands fall into two categories:
- Fire-and-forget (
focus,highlight,caption) — start and returnokimmediately (or when their tween settles). The runtime does not block on them. - Self-managed waiting (
wait: tap, a dialog) — the command resolves its own promise when its condition is met. The runtime just awaits it.
Fire-and-forget commands still get a run context and a cancel path. A focus
tween superseded by a newer focus must be cancellable, or two cameras fight.
"Fire-and-forget" means the runtime doesn't await it, not that it has no
lifecycle.
Supersede groups cancel a running command when another in the same group
starts. A focus command belongs to a camera group, so a second focus
cancels the first.
interface Command {
id: string;
supersede?: string; // group; starting one cancels others in it
execute(ctx: CommandContext): Promise<CommandResult>;
}
4. tap interaction
Only tap interaction is supported. A tap on a part is detected and reported to
the command layer as a TapEvent. Parts may declare trigger points —
named, circular regions the author wants to be tappable.
interface TriggerPoint {
id: string;
position: [number, number]; // part-local frame, mm
radius: number; // mm
}
interface TapEvent {
part: string; // package:type#id
position: [number, number]; // part-local frame, mm
trigger: TriggerPoint | null; // nearest within radius, or null
}
Rules:
- Trigger points are authored in the part's local frame (mm, relative to the part's origin), not world space. A part moves, rotates, and flips (facing), so a world-space point would break the moment it moves. The tap point is transformed into the part's local frame at tap time.
- Distance is measured in the part's plane. The reported trigger point is the
nearest one within its
radius; ties go to the first declared. - Every tap on the part is reported, with the nearest trigger point (or
nullwhen none is in range). The command decides how to react — resolve, reject with a "wrong spot" shake, or ignore. The runtime stays dumb; the command owns the UX.
Commands subscribe to the tap stream via the context and unsubscribe on
cancel, so a cancelled wait: tap never leaks a handler.
5. command context
The context a command receives is the handle to everything it can affect:
interface CommandContext {
pkg: Package;
store: TabletopStore; // movePart, setPart, enableSurface, ...
onTap(handler: (e: TapEvent) => void): () => void; // returns unsubscribe
// camera, highlight, and overlay handles are added as those subsystems land
}
6. where trigger points come from
The tap detector reads trigger points from a runtime map keyed by part id; it
does not care where they are declared. Declaration (on the part definition, in
a setup, or in a script) is the deferred "how to declare" half and lives in
bgm-format.md.
Open decisions
- Where commands are declared — the
scriptrole and its schema (bgm-format.md), deferred. - Animation — a general "ease toward target placement" layer (preferred) vs explicit per-move tweens.
- Camera —
CameraControls(drei) vs hand-rolled. - Triggering — does a setup reference a script to auto-run, or is a script a separate page the player picks?
- Narration — pre-recorded audio assets per script, or TTS at runtime?