docs: add free interaction design to bgm specs

Add the command/dialog split to the state-model and interactions docs, declare role dialog and zone facing in the format spec, and let setups declare which dialogs power open interactions.
This commit is contained in:
2026-08-16 12:59:03 +08:00
parent 211c151971
commit addfc03ab6
3 changed files with 196 additions and 0 deletions
+62
View File
@@ -180,6 +180,7 @@ by their `role=` (or, for real files, their filename) for either
- `part` - `part`
- `surface` - `surface`
- `setup` - `setup`
- `dialog`
a valid object can either be the root or in the list of the yaml block. a valid object can either be the root or in the list of the yaml block.
@@ -236,6 +237,10 @@ placed on the board via `setup`, and visualized by routes.
- `size` — `size`. The token is scaled to fit in the box. The x/y aspect - `size` — `size`. The token is scaled to fit in the box. The x/y aspect
ratio is kept, but not z (thickness). ratio is kept, but not z (thickness).
- `fillet` — number in mm. Used to fillet the shape. Defaults to `0`. - `fillet` — number in mm. Used to fillet the shape. Defaults to `0`.
- `facing` — the **physical affordance**: the facings the piece supports.
A card supports `[face, back, standing]`; a tile supports `[face, back]`.
Declares what's *possible*, not what's legal on a given zone (see `surface`
`layout`). Defaults to `[face]`.
#### example #### example
@@ -366,6 +371,49 @@ initialization.
A part's `facing` is seeded into the game state and can change at runtime; it A part's `facing` is seeded into the game state and can change at runtime; it
only affects orientation, never the part's texture. only affects orientation, never the part's texture.
A setup also declares the **interaction affordances** — which dialogs are the
tool for which open interactions on which paths:
```yaml
interactions:
- dialog: insert
on: [draw, grid] # insertion uses the `insert` dialog on these paths
- dialog: shuffle # open the shuffle dialog on any deck
```
`interactions` is a list of declarations: each names a `dialog` (a `role:
dialog` definition, see below) and the paths it applies to (`on`, matching by
path or by stack). It declares the *interaction surface*, not the legality of
the resulting command — rule scripts (later) gate legality. A dialog can be
opened by a player gesture or pushed by a rule script; only the trigger differs.
### dialog
A `dialog` is declarative content shown in the layer-3 shell. Opening and
closing it never issues a command or mutates state; it is pure UI. Its **action
buttons** issue commands — the bridge between the dialog and the rule seam.
```yaml
role: dialog
type: prompt
id: discard
title: Choose a card to discard
body: |
Select a card from your hand.
actions:
- label: Confirm
command: { move: { part: "#chosen", to: /discard } }
widget: stack # a stack-of-parts content type
```
A dialog's content is a **title**, **body**, **action buttons**, and an optional
`widget`. Supported content types include a **stack of parts** — an ordered,
scrolled view of a path's stack with an insertion cursor — which is what powers
insertion and shuffling dialogs against the state store.
`dialog` is a same-shaped definition as the others: `type`/`id` identify it
(`type#id` unique in the package), and it collides-checks like `part`/`setup`.
--- ---
## 4. Concepts ## 4. Concepts
@@ -384,6 +432,11 @@ 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 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). progresses (e.g. enabling the main board after an expansion-chooser scene).
Dialogs are **not** part of the state store. The dialog stack is UI state
hosted by the shell; opening/closing a dialog never mutates the store. A dialog's
action buttons issue commands (see `role: dialog` §3), which is the one-way
bridge onto the state.
### Routing ### Routing
A route is a **visualization route**: it maps a part to a location on a A route is a **visualization route**: it maps a part to a location on a
@@ -399,6 +452,15 @@ 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 `rotation` of its anchor. Routes are defined in a **list**, not a map, so the
same route path may appear more than once: same route path may appear more than once:
A route may also declare a zone `facing` — the **legal facing** on that zone.
This constrains what the placed parts may be, independent of each part's
physical affordance (see `part` `facing`). A state is legal only when the part's
facing is in both the part's affordance *and* the zone's legal set. When omitted,
the zone is unrestricted beyond the part's affordance. An MTG discard pile
requires `[face]`; a play area allows `[face, tap]`; a facedown deck requires
`[back]`. The same card is face-up in the discard but tapped in play — the
piece's affordance is unchanged, only the zone's rule differs.
```yaml ```yaml
layout: layout:
- route: /dock/:seat - route: /dock/:seat
+133
View File
@@ -0,0 +1,133 @@
# bgm Interactions
The free-interaction layer (layer 3 of the layering) — how a player interacts
with a bgm game that has no rules yet: a sandbox. It builds on the state model:
see [`state-model.md`](./state-model.md) for components-vs-setup, path→stack ×
facing, and the anchoring scope.
> **Status:** Design. Proposes the operation set, the command/dialog split, and
> the deck-pick-up dialog before the interaction half of `@tts/tabletop` is
> built.
## 1. The operation set is closed and tiny
Two assumptions from the state model do most of the work:
1. **Parts are never created or destroyed** — the set of parts is fixed (the
setup's parts).
2. **State is only path, stack, and facing** — there is nowhere to store a free
position in space.
Together they collapse the *entire* space of free interaction into three
operations:
1. **`move(id, path, index?)`** — relocate a part to a path, at a stack
position (default: top of stack).
2. **`setFacing(id, facing)`** — change facing, within the part's physical
affordance.
3. **reorder** — a `move` with an explicit `index`.
That's it. There is no arbitrary positioning and no free placement in 3D because
the state model has nowhere to store one. "Free" means *unconstrained over these
three operations*, not "free in space."
## 2. Free interaction and rule play are the same operations
This is the payoff. Rule-enforced play (layer 4) is **the same three
operations, gated by a legality check**:
- The interaction layer produces an **intent** — "player wants
`move(card, /discard)`."
- **Sandbox mode**: apply it directly.
- **Rule mode**: validate the intent against the setup's shape + the rule script
first; reject if illegal.
So the rule engine needs no interaction vocabulary of its own — it is a **filter
over free interaction**. `move`/`setFacing` are the shared primitives; rules
decide which are legal in the current state. Drag-and-drop and a scripted move
both funnel through the same store mutation. That is the seam between layer 3
and layer 4.
## 3. Commands vs the dialog stack
There are two channels, and they do not mix:
- **Commands** — intent to *change state* (`move`, `setFacing`). They go through
the rule seam and mutate the store.
- **The dialog stack** — transient UI contexts (the deck pick-up, a "confirm
discard," a hint prompt). Opening/closing a dialog **never issues a command**
and never touches state; it is pure UI.
This simplifies the model. The dialog stack is **UI state, hosted by the layer-3
shell, not by the game-state store** — dialogs don't belong in path/stack/facing.
How they connect — one direction only:
- **Player-initiated**: a click on a deck pushes the deck dialog; the dialog's
insert button issues a `move` command.
- **Script-initiated**: a rule script pushes the same dialog (e.g. to force a
discard) and awaits the player's `move` through it. The script's open/close
is tied to the dialog stack; it can `pushDialog`/`popDialog` without mutating
game state.
So the deck dialog is **one implementation, driven either way** — by a player
click or by a rule script. Only the *trigger* differs.
Dialogs are **authored in the manifest**, not hardcoded. A `role: dialog`
definition declares the title, body, action buttons, and an optional widget
(a stack of parts). Setups declare which dialogs are the tool for which
interactions via `interactions:` (see [`format.md`](./format.md) §3). The
rule seam stays clean because a dialog's buttons issue commands while its
open/close is stack-only.
## 4. Compound interactions: the deck pick-up dialog
A dialog is where compound, multi-step manipulation lives, because it owns the
transient sub-state that the store must not. The prime example — inserting a
card into the middle of a deck:
1. pick up the deck
2. scroll through it to find the place
3. insert the card at the cursor
4. put the deck back
(even 0: put down your held hand of cards first).
The dialog is an **alternate view of the stack**: the deck is "lifted" off the
table into the dialog (it stays on its path; the rest of the table becomes
backdrop). Its contents are shown **in order**, with an **insertion cursor**
between cards that you scroll. The cursor *is* the index: `move(id, path,
index)`'s index is discovered by scrolling the visible deck, not typed.
This is a real interaction *mode*, owned by the dialog:
- The scroll position is state.
- Everything else pauses while it's open (or the held part is kept visible).
- It only exists for **stacks**; single parts just snap onto a path.
We treat it as a **reusable pattern** — a stack-inspector dialog — not a one-off
hack for one command, so `deal`, `draw`, and `look-at-the-top` can reuse the
same lifted-deck view.
## 5. The primitives map to concrete gestures
- **Move** — pick up a part → it leaves its stack (transient "in hand"); drag →
resolve the nearest path anchor within a threshold; drop → `move` (drop on
nothing returns to origin).
- **Facing** — click cycles the part through its physical affordance
(`face → back → standing`, or the declared list).
- **Reorder / insert** — the deck dialog above.
The "in hand" state is transient and illegal, so it lives **outside the store**
(a UI-level held part); only the committed outcome (drop) mutates the store.
## Open questions
- **Held source for insertion** — single held card (fits the physical model; the
dialog inserts it at the cursor), vs the dialog owns the source (you cursor a
card *from* the deck to lift). Lean single-held-card, but confirm it doesn't
fight the hand step.
- **Multi-part ops** — picking up a whole stack, dealing N cards. Deferred; the
single-part primitives are the foundation.
- **Scroll window** — a window over a subset of the deck can return with the
cursor's scroll position; exact widget is a render concern, not a state one.
+1
View File
@@ -15,6 +15,7 @@ and go stale as work lands.
| [`bgm/commands.md`](./bgm/commands.md) | bgm command execution: lifecycle, run contexts, tap interaction | | [`bgm/commands.md`](./bgm/commands.md) | bgm command execution: lifecycle, run contexts, tap interaction |
| [`bgm/tabletop.md`](./bgm/tabletop.md) | The r3f tabletop component library | | [`bgm/tabletop.md`](./bgm/tabletop.md) | The r3f tabletop component library |
| [`bgm/state-model.md`](./bgm/state-model.md) | The format's state model: components vs setup, facing, anchoring scope | | [`bgm/state-model.md`](./bgm/state-model.md) | The format's state model: components vs setup, facing, anchoring scope |
| [`bgm/interactions.md`](./bgm/interactions.md) | Free interaction: the operation set, the rule seam, and the deck pick-up dialog |
## Status & plans (dev logs) ## Status & plans (dev logs)