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.
558 lines
18 KiB
Markdown
558 lines
18 KiB
Markdown
# 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`
|
||
- `dialog`
|
||
|
||
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`.
|
||
- `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
|
||
|
||
```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.
|
||
|
||
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
|
||
|
||
### 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).
|
||
|
||
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
|
||
|
||
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:
|
||
|
||
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
|
||
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).
|