From 664079528c066774698ad34cb4ffe125a0899dfa Mon Sep 17 00:00:00 2001 From: hypercross Date: Sun, 9 Aug 2026 18:14:43 +0800 Subject: [PATCH] docs: add board game manifest specification --- docs/bgm-format.md | 344 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 docs/bgm-format.md diff --git a/docs/bgm-format.md b/docs/bgm-format.md new file mode 100644 index 0000000..ffb18bc --- /dev/null +++ b/docs/bgm-format.md @@ -0,0 +1,344 @@ +# Board Game Manifest — Technical Reference + +> The concrete behavior of the board game manifest (bgm) format. This is the +> companion to the informal [`bgmanifest.md`](./bgmanifest.md) spec; where the +> two disagree, this document is authoritative. +> +> 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. +> +> Assume every `.md` file in the package is preprocessed for virtual files: +> its code blocks are extracted and treated as definitions before any other +> resolution happens. + +--- + +## 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 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`. + +```yaml +$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: + +````md +```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: + +```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. + +```yaml +type: board +id: harbor +role: surface +size: [300, 200] +layout: + - route: /dock/:seat + candidates: + $variants: ./seats.csv + - route: /deck + x: -100 + y: 0 + rotation: 0 +``` + +### setup + +`setup` seeds the state store. Each valid game state is a valid setup. + +```yaml +role: setup +type: game +id: main +setup: + /dock/0: harbor:boat#fleet + /deck: harbor:card +``` + +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**: a map from path to a **stack** of +parts. It is the authoritative record of 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. + +### 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). + +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 +``` + +- `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. + +#### 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).