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.
This commit is contained in:
2026-08-16 11:57:19 +08:00
parent cf8ca07850
commit 345832e389
19 changed files with 79 additions and 43 deletions
+156
View File
@@ -0,0 +1,156 @@
# bgm-commands
Command execution for [bgm](./format.md) board games, built into
[`@tts/tabletop`](./tabletop.md). 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`](./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.
```ts
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.
```ts
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`):
```ts
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.
```ts
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:
```ts
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.
- **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?
+260
View File
@@ -0,0 +1,260 @@
# bgm-engine
The message layer that drives [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
[`commands.md`](./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`](./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<Name extends string, Args> {
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 `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<Name extends string, R = void> =
| { 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<void> {
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<Args, Result> = (ctx: RunContext & { args: Args }) => Promise<Result>;
```
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<Message>; // 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.
+495
View File
@@ -0,0 +1,495 @@
# Board Game Manifest — Technical Reference
> The concrete behavior of the board game manifest (bgm) format.
>
> Definitions can live in JSON/YAML/TOML files or in markdown code blocks. In
> codeblock mode, each code block is a virtual definition file, named relative
> to the current markdown file.
---
## 1. json features
### The `$variants` directive
For objects with a `$variants` key, the value is a CSV. Parse it into an object
array with `typed-csv`, extend the original object with each row, and return
the array.
```yaml
job: 'hero'
$variants: ./heroes.csv
```
```csv
name,parents
string,string[]
clark,[jonathan;martha]
bruce,[]
```
```json
[
{ "job": "hero", "name": "clark", "parents": ["jonathan", "martha"] },
{ "job": "hero", "name": "bruce", "parents": [] }
]
```
### Inline vs file
`$variants` can be a single source or an array of sources. Each source is a
file/URL path if its first line ends in `.csv`, otherwise it is inline CSV.
This keeps the two forms self-documenting and applies the same rule to single
values and array elements alike. In YAML a block scalar (`|`) is the natural
way to write inline CSV; in JSON you'd use `\n`.
```yaml
$variants: |
id,name,faceCrop
string,string,[number;number;number;number]
fish,Fish,[0;0;5;2]
grain,Grain,[1;0;5;2]
```
An array of sources concatenates their rows. This lets one part definition
pull from several CSVs with different schemas — e.g. a deck where the regular
cards share a face sheet but the jokers have their own:
```yaml
$variants:
- ./cards.csv
- ./jokers.csv
```
Each source is parsed with its own schema, and its rows extend the original
object independently.
### CSV conventions
CSV is parsed with `typed-csv`:
- The first row is the header, the second row is the type declaration
(`string`, `number`, `string[]`, ...), and the remaining rows are data.
- Rows are validated against a zod schema derived from the type row.
- **`crop` inside a CSV cell** uses `;` as the element separator
(`[0;0;5;2]`), because `,` is the CSV delimiter. `typed-csv` loads it into
an array with value `[0,0,5,2]`.
---
## 2. Definition discovery
Definitions are organized in **packages**. A loader loads a package
declaration, then uses its `include` paths to find the definitions.
### Code blocks as virtual files
A code block is a virtual definition file. Its name is derived from the
`role=` on its info string — `role.type.lang` — so it is discoverable by the
default `include: ./**/*.yaml` and addressable by that name:
````md
```yaml role=part.cargo
...
```
```yaml role=surface.game#main
...
```
```yaml role=package
...
```
````
- `role=part.cargo` names the block `part.cargo.yaml`.
- `role=surface.game#main` names it `surface.game.yaml`.
- `role=package` names it `package.yaml`.
- The name is what `$variants: ./cargo.csv` and `include: parts/*.yaml`
resolve against. When there is a real file in that path, the codeblock wins.
- `file=` overrides the auto-name when present, e.g. `file=parts/cargo.yaml`
names the block `parts/cargo.yaml` regardless of its role.
- A block without `role=` is **not** a definition — it is ignored. Discovery
is explicit: a block is a definition only when its `role=` (or, for real
files, its filename) declares a known `role.type`.
### role= on the info string
A block's role is declared on the info string, using the same `role.type#id`
shape as the block's identity. `type` and `id` are optional — anything not
given comes from the content (or from `$variants` rows):
````md
```yaml role=part.cargo
...
```
```yaml role=surface.game#main
...
```
```yaml role=package
...
```
````
- `role=part.cargo` declares a part of type `cargo`; its `id` comes from the
content or from `$variants`.
- `role=surface.game#main` declares a surface of type `game` with id `main`.
- `role=package` declares a package; it has no type.
- A `role`/`type`/`id` given on the info string **conflicts** with the same
key in the content and errors. `id` on the info string cannot combine with
`$variants`, since every row supplies its own `id`.
- A block without `role=` is not a definition — discovery is explicit (see
above).
### Real files
A real `role.type.lang` file (e.g. `part.cargo.yaml`) is a definition by its
filename, with no `role=` needed. `role` and `type` are parsed from the name;
`id` comes from the content or `$variants`. A real file and a code block with
the same name are the same definition; the code block wins.
### Duplicates
Two definitions with the same `role.type` are grouped under the same name.
They must not define the same `id` — a duplicate `type#id` errors. Blocks with
the same `role.type` but different ids are fine.
### include
`include` is a list of git-style path patterns — the defs that make up the
package. **Defaults to `./**/*.yaml`**, so all yaml in the same and sub
folders is discovered with no configuration. This also matches the package
declaration itself, which is fine — it's the package, not a part.
Patterns are resolved **relative to the package declaration's own directory**,
not the games root. So a package declared in `carcassonne/carcassonne.md`
with the default `./**/*.yaml` only picks up yaml under `carcassonne/` — it
never absorbs defs from a sibling game. To reach outside its folder, a
package can use a `../`-relative pattern or an absolute-from-root pattern
(e.g. `**/shared/*.yaml`).
---
## 3. Roles
json objects in yaml blocks are handled if they are declared as a definition
by their `role=` (or, for real files, their filename) for either
- `package`
- `part`
- `surface`
- `setup`
a valid object can either be the root or in the list of the yaml block.
for all roles except package, `type` and `id` are needed.
`type#id` is used for identification so that combo must be unique in the package.
A block declares its role on the info string — `role=part.cargo` is equivalent
to `role: part` + `type: cargo` in the content (see §2). A real file declares
it in its filename. The info string/filename and content must not both set the
same key.
### package
The package is the container for a game's definitions. It is declared with a
`role: package` object:
```yaml
role: package
id: harbor
title: Harbor
designer: Jane Doe
players: 2
language: en
```
- `role`: for block discovery.
- `id`: package identification.
- `title` — game name.
- `include` — the defs that make up the package (see §2).
- Optional metadata: `designer`, `development` (artist/developer), `publisher`,
`players` (player count), `language`.
### part
A part is a game component. It is identified by a `package:type#id` string,
placed on the board via `setup`, and visualized by routes.
#### part value types
- `image` — a url to an image.
- `crop` — a tuple `[col, row, cols, rows]`. Divides the image into a grid
and picks the cell at `[col, row]` with size `[width/cols, height/rows]`.
Negative `cols` flips the rendered image.
- `size` — a tuple `[width, height, depth]` in mm units.
#### part props
- `face` — `sprite`. Used for texture.
- `faceCrop` — `crop` for `face`.
- `back` — `sprite`. Used for texture. Defaults to `face`.
- `backCrop` — `crop` for `back`.
- `shape` — `sprite`. Traced for its profile to create the mesh for the part.
Defaults to the full rect of the back image.
- `size` — `size`. The token is scaled to fit in the box. The x/y aspect
ratio is kept, but not z (thickness).
- `fillet` — number in mm. Used to fillet the shape. Defaults to `0`.
#### example
```yaml
role: part
type: token
id: wood
face: ./assets/tokens.png
faceCrop: [1, 0, 5, 2]
back: ./assets/tokens.png
backCrop: [3, 0, 5, 2]
shape: ./assets/token-shape.png
size: [20, 20, 3]
fillet: 2
```
A `wood` token: the `face` and `back` sprites come from the same sheet,
`faceCrop`/`backCrop` picking different cells of the `5×2` grid. The shape is
traced from `token-shape.png`, sized `20×20×3` mm with a `2` mm fillet.
### surface
A `surface` is a **view** over the state store, purely for **visual rendering**.
It has a reference `size` (`[width, height]` in mm) and a `layout` list of
routes. The size is a reference — it may be scaled to fit larger or smaller
tables. It does not affect part placement; placement lives in the state store
(see §4). A surface need not cover every part — parts with no matching route on
this surface are simply not shown.
A surface also declares how it is **mounted**: as the root table surface, on a
HUD area, or as a child of another surface. `mount` is always an object, with
`x`, `y`, and `rotation` (defaulting to `0`) anchoring it like a route. The
`kind` selects the mount type:
- `table` — the root table surface (default).
- `hud` — mounted to a HUD area, e.g. a player's hand.
- `child` — mounted relative to a parent surface. A surface lists its
`children` (`type#id` refs) so a surface can be repeated, like a player
board; each child is mounted relative to its parent's anchor.
```yaml
type: board
id: harbor
role: surface
size: [300, 200]
mount:
kind: table
x: 0
y: 0
rotation: 0
children:
- board#player
layout:
- route: /dock/:seat
candidates:
$variants: ./seats.csv
- route: /deck
x: -100
y: 0
rotation: 0
```
```yaml
type: hud
id: hand
role: surface
size: [200, 100]
mount:
kind: hud
area: bottom-left
```
```yaml
type: board
id: player
role: surface
size: [200, 200]
mount:
kind: child
x: 100
y: 50
rotation: 0
```
### setup
`setup` seeds the state store: the enabled surfaces and the part placement.
Each valid game state is a valid setup.
```yaml
role: setup
type: game
id: main
surfaces:
- board#harbor
- hud#hand
setup:
- path: /dock/0
parts: harbor:boat#fleet
- path: /deck
parts: harbor:card
facing: back
- path: /table
parts: harbor:token#wood
facing: standing
```
`surfaces` lists the surfaces enabled at the start. A surface not listed is
disabled and not rendered. When `surfaces` is omitted, all surfaces are
enabled.
`setup` is an **ordered list of placements**. Each placement moves its `parts`
to its `path`, and entries are applied in order — so a part listed in a later
placement ends up on that placement's path. This makes a setup read like "deal
the deck, then move these cards to the flop".
`parts` can be a single part id, a bare type without an id, or a list of
either. A bare type expands to all parts of that type during game state
initialization.
`facing` sets how the placed parts are oriented on the board, defaulting to
`face`:
- `face` — lay flat, front up, resting on the bottom face.
- `back` — lay flat, front down (flipped over), resting on the top face.
- `standing` — stand upright on the bottom edge, front texture still showing.
A part's `facing` is seeded into the game state and can change at runtime; it
only affects orientation, never the part's texture.
---
## 4. Concepts
### Game state
The board's state is a **state store**: the set of **enabled surfaces** and a
map from path to a **stack** of parts. It is the authoritative record of which
surfaces are in play and where every part is placed.
A path is a URL path with named params, like `/dock/1`.
A part is identified by a `package:type#id` string.
A surface is enabled or disabled; a disabled surface is not rendered. Setup
seeds the enabled set (see §3), and it changes at runtime as the game
progresses (e.g. enabling the main board after an expansion-chooser scene).
### Routing
A route is a **visualization route**: it maps a part to a location on a
surface. Routes match the keys of the state store, but they are defined by a
surface and need not cover every placed part — a part with no matching route on
a given surface is simply not shown there. Routes exist only for game parts; a
surface is not a part and never appears on a route.
A route matches all parts on the path; the placement of each individual part on
the stack is a separate concern.
A route is an express-style URL path with named params, plus the `x`, `y`, and
`rotation` of its anchor. Routes are defined in a **list**, not a map, so the
same route path may appear more than once:
```yaml
layout:
- route: /dock/:seat
x: 40
y: 0
rotation: 0
- route: /deck
x: -100
y: 0
rotation: 0
```
### Candidates
To match a class of routes against a list of positions, keep a single route with its param and give it a
`candidates` array to match `:param` against, each candidate carrying its own `x`/`y`/`rotation`:
```yaml
layout:
- route: /dock/:seat
candidates:
$variants: ./seats.csv
```
```csv
seat,x,y,rotation
string,number,number,number
0,40,0,0
1,40,20,0
```
The router should select only the first candidate with all params matched against its props — the fields in the candidate's CSV row (e.g. `:seat` matches the candidate's `seat` value).
A candidate inherits the route's `x`, `y`, `rotation`, and `stacking`, and may override any of them with its own values. When no candidates match, the whole route fails to match.
### Stacking
When multiple parts live on a path, only the top (last) one shows by default.
To override this, add stacking strategies:
```yaml
layout:
- route: /deck
x: -100
y: 0
rotation: 0
stacking:
curve: M 0 0 C 20 -20 40 -20 60 0
limit: 5
align: center
steps: 4
tilt: 0.1
zStart: 0
zEnd: 30
```
- `curve` — an SVG path string to spread the content along, relative to the
anchor `x`, `y`, `rotation`.
- `limit` — how many parts to display. `0` shows all, `3` shows the first 3,
`-3` shows the last 3.
- `align` — `start`, `end`, or `center` of the curve.
- `steps` — the maximum number of parts per curve length unit. Defaults to
`1`. See the positioning process below.
- `tilt` — rotation in degrees applied to every shown part about the card's
local Y (long) axis. It applies even without a `curve`, so a bare `tilt`
rotates a straight pile. Defaults to `1` when not specified.
- `zStart` / `zEnd` — the height (surface-normal) in mm at the start and end
of the `curve`. The stack ramps linearly between them across its span,
lifting it in 3D. Requires a `curve`.
#### positioning process
1. **Determine the step length.** It is `curve length / max(steps, # of
parts on path 1)`.
2. **Determine the alignment.** It places the span of
`step length × (# of parts 1)` on the curve.
3. **Place each part.** Part `#0` is at the start, the last part at the end,
each `step length` apart.
4. **Lift each part.** The part's height is `zStart + (zEnd zStart) × u`,
where `u` is its normalized position along the `curve`.
5. **Tilt each part.** Every part is rotated `tilt` about its local Y (long)
axis.
### Edge cases
- Object with no matching route → **not placed on this surface**. The game
state is still valid — the part simply isn't visualized. A surface is a view
over the state store, not a mirror of it, and may show only a subset (e.g. a
player's hand on the HUD).
- Route with no matching object → empty, fine.
- Multiple routes match one path -> first route wins.
- Multiple parts on one path → **stack** (see §4 Stacking). One route wins
for all parts on a path, and the stacking strategy decides what's shown
(it may drop parts that are not dropped on other matching routes).
+64
View File
@@ -0,0 +1,64 @@
# bgm-tabletop
a r3f based interactive component library to work with [bgm](./format.md) board games. will be used somewhere in the `web` app's bgm inspector routes.
## 1. stack
`react` - react, react router, tailwindv4
`r3f` - r3f, drei, postprocessing
`zustand` - for state management
## 2. states
source-of-truth game state:
```ts
{
surfaces: Record<string, boolean>, // enabled per surface id
parts: Record<string, PartState>, // part id -> placement state
}
interface PartState {
path: string, // the path key this part is on
index: number, // the part's position in its path's stack
facing: 'face' | 'back' | 'standing', // how the part is oriented on the board
}
```
**assumption:** each piece on the board has a unique id, even tokens of the same type. so a part id appears at most once, and a path's ordered children (for stacking) are derived from the map by sorting on `index`. this makes the render list keyed by piece id stable and unambiguous.
derived surface render state: game state + surface routes => map of piece id to `{ surface, route, candidate, index, stackSize, facing }` for rendering on a surface. keys of this map makes a stable render list.
- `route` - the matched route.
- `candidate` - the matched candidate for a `:param` route, carrying its anchor `x`/`y`/`rotation`. absent for routes without candidates.
- `index` - the piece's position in its path's stack.
- `stackSize` - the number of pieces on the path.
- `facing` - how the piece is oriented on the board (`face` / `back` / `standing`).
the render map is per enabled surface: a piece may appear on more than one enabled surface (e.g. an expansion path and the main board), and each is rendered independently.
## 3. components
- `SetupLoader` side effect only component that seeds the game state with setup (enabled surfaces + part placement).
- `WorldSurfaceView` mounts a surface to world space.
- `HudSurfaceView` mounts a surface to hud space.
- `PartPlacement` a stable per-part component that positions a part on a surface location. uses the stacking hook (below) to apply the route's stacking strategy.
- `PartView` used in `PartPlacement`, creates a mesh from part definition. a standalone component library, so it reuses geometry/shape code from `@tts/mesh` rather than the web app's viewers.
## 4. stacking
the format's stacking strategy (`curve` / `limit` / `align` / `steps` / `tilt` / `zStart` / `zEnd`, see `format.md` §4) is implemented as a hook, e.g. `useStacking(route.stacking, index, stackSize)`, returning the offset/rotation to apply to a piece: `{ x, y, rotation, z, tilt }`. `x`/`y`/`rotation` come from the `curve`; `z` is the surface-normal height ramped from `zStart` to `zEnd`; `tilt` is the rotation about the card's local Y (long) axis, applied to every part. `PartPlacement` consumes it.
## 5. commands
Scripted interaction — focus, tap-to-advance, move, caption — is built on an
async command layer. See [`commands.md`](./commands.md) for command
execution (lifecycle, run contexts, tap interaction), and
[`engine.md`](./engine.md) for the message layer above it (the queue,
triggers, and orchestrators that declare and fire commands).
## 6. usage
- we will inspect individual parts with `PartView` in the web app's part inspection route.
- as a library, the public surface is the components above: mount a surface with `WorldSurfaceView`/`HudSurfaceView`, seed state with `SetupLoader`, and let `PartPlacement`/`PartView` render the pieces. the web app is one consumer; the library should not assume the web app's routes or store.
- a surface is mounted only when enabled; a disabled surface is not rendered.