Files
tts-workshop/docs/bgm/commands.md
T
hypercross 345832e389 docs: reorganize docs into bgm and status folders
Group the bgm spec cluster under docs/bgm and move dev logs and plans under docs/status, add an overview index, and update cross-references in the docs, README, and source comments.
2026-08-16 11:57:19 +08:00

6.1 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 engine.md. Commands are async functions registered with the engine's handler registry; @tts/tabletop provides the concrete commands that mutate the tabletop store and render layer.

1. async commands

A command is an async function that returns a result. Every command ends in one of three states, emitted as a message discriminated on the type suffix (see engine.md §3):

  • :done — 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<Name extends string, R = void> =
  | { type: `${Name}:done`; data: R }
  | { type: `${Name}:cancel` }
  | { type: `${Name}: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 return ok immediately (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. A superseded command's signal is aborted, and it emits :cancel.

A command is an async function taking the RunContext (with its args):

type Command<Args, Result> = (ctx: RunContext & { args: Args }) => Promise<Result>;

The engine wraps it: it builds the context from the message, runs the function, and emits :done on resolve, :cancel on abort, :error on throw.

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 null when 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. run context

The context a command receives is the handle to everything it can affect. The engine defines the base RunContext (see engine.md §5): signal (cancellation), emit, wait, and enableTrigger/disableTrigger. Tabletop extends it with the handles commands need to mutate the board:

interface TabletopRunContext extends RunContext {
  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 format.md.

Open decisions

  • Where commands are declared — the script role and its schema (format.md), deferred.
  • Animation — a general "ease toward target placement" layer (preferred) vs explicit per-move tweens.
  • CameraCameraControls (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?