Files
tts-workshop/docs/bgm/format.md
T
hypercross 345832e389 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.
2026-08-16 11:57:19 +08:00

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

$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:

$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:

```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):

```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:

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:
  - 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:

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

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:

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