496 lines
15 KiB
Markdown
496 lines
15 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`
|
||
|
||
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).
|