Files
tts-workshop/docs/bgm-format.md
T

11 KiB
Raw Blame History

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.

job: 'hero'
$variants: ./heroes.csv
name,parents
string,string[]
clark,[jonathan;martha]
bruce,[]
[
  { "job": "hero", "name": "clark", "parents": ["jonathan", "martha"] },
  { "job": "hero", "name": "bruce", "parents": [] }
]

Inline vs file

$variants can be a file/URL path or an inline CSV string. If the value contains a newline it is inline CSV; otherwise it is a path. In YAML a block scalar (|) is the natural way to write inline CSV; in JSON you'd use \n.

$variants: |
  id,name,faceCrop
  string,string,[number;number;number;number]
  fish,Fish,[0;0;5;2]
  grain,Grain,[1;0;5;2]
  wood,Wood,[2;0;5;2]

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. To give it a name — so include: and $variants paths can resolve against it — add a file= segment to the code block's info string. The name is relative to the current markdown file:

```yaml file=parts/cargo.yaml
...
```

```csv file=parts/cargo.csv
...
```
  • A block with file= is addressable by that path.
  • A block without file= is auto-named ./${hash}.yaml, where hash is derived from its content. This makes every yaml block naturally discoverable by the default include: ./**/*.yaml. Identical blocks dedupe to the same hash.
  • The file= 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= implies the file type from its extension; the language tag is optional and only for editor highlighting.
  • Hash vs explicit file=: a hashed name is for auto-discovery, not for referencing. To point at a specific yaml block by name, give it an explicit file=; otherwise its name is content-derived and unstable.

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.


3. Roles

json objects in yaml blocks are handled if they have a role: field 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.

package

The package is the container for a game's definitions. It is declared with a role: package object:

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

  • facesprite. Used for texture.
  • faceCropcrop for face.
  • backsprite. Used for texture. Defaults to face.
  • backCropcrop for back.
  • shapesprite. Traced for its profile to create the mesh for the part. Defaults to the full rect of the back image.
  • sizesize. 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

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.
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
type: hud
id: hand
role: surface
size: [200, 100]
mount:
  kind: hud
  area: bottom-left
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.

role: setup
type: game
id: main
surfaces:
  - board#harbor
  - hud#hand
setup:
  /dock/0: harbor:boat#fleet
  /deck: harbor:card

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.

The value on a setup path can be either a string, or a string list.

The string can either be a one part string, or a type without an id.

When id is omitted, it expands to all parts in that type during game state initialization.


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:

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:

layout:
  - route: /dock/:seat
    candidates:
      $variants: ./seats.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).

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:

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
  • 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.
  • alignstart, end, or center of the curve.
  • steps — the maximum number of parts per curve length unit. Defaults to 1. See the positioning process below.

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.

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).