Introduction
OpenTabletop is an open specification for board game data – a schema, vocabulary, and API contract that defines what a board game database should look like. Think of it as the MusicBrainz of tabletop gaming: a community-governed standard that anyone can implement with their own data. OpenTabletop is not a database. It is the blueprint for building one.
The Problem
Board game data is in a crisis:
- BoardGameGeek is the de facto monopoly on board game metadata, but its API is an undocumented XML endpoint from the mid-2000s. It is rate-limited, fragile, and missing basic capabilities like filtering by player count and play time simultaneously. There is no official documentation, no versioning, and no contract – it can change without notice. Worse, BGG does not facilitate others building on its data – no bulk exports, no interoperability, no ecosystem. The data goes in and it does not come out.
- Board Game Atlas attempted to be an alternative and then shut down entirely, taking its API and everyone’s integrations with it.
- No standard exists. Every board game app, collection tracker, and recommendation engine scrapes BGG or maintains its own ad-hoc database. There is no shared vocabulary, no common schema, no interoperability.
This is the state of the art: a single proprietary website with an XML API that was never designed to be an API, and no fallback when it goes down or changes behavior. A little competition could do the space some good – but competition requires a shared foundation to build on.
OpenTabletop exists to provide that foundation. Not by replacing BGG as a community – BGG is a great forum and review site – but by defining the specification so that multiple platforms, apps, and databases can exist, interoperate, and compete. Any developer can stand up a conforming server with their own data. Any app can consume any conforming API. The specification is the commons; the implementations are the marketplace.
Critically, the specification is language-agnostic and designed for global adoption. It does not assume English, does not assume BGG’s voter population, and does not assume a Western-centric hobby. A Japanese board game community can run a conforming server with Japanese game names, Japanese community data, and Japanese voter preferences. A German community can do the same. A Brazilian community can do the same. All of these servers speak the same API contract – an app built against any one of them works against all of them. Games carry alternate names in any language, voting data is disaggregated by community, and the taxonomy uses canonical slugs that implementations surface in their own language. The standard enables a global ecosystem where regional communities maintain their own data while remaining interoperable.
The Three Pillars
The project is organized around three pillars, each solving a distinct part of the problem:
Pillar 1: Standardized Data Model
A rigorous, relational data model for board games and everything associated with them. Games, expansions, designers, publishers, mechanics, categories, player counts, play times, complexity weights – all with well-defined types, relationships, and identifiers.
The data model handles the hard problems: an expansion that changes the player count of its base game. A standalone expansion that is both its own game and part of a family. A reimplementation that shares mechanics but is a distinct product.
Pillar 2: Filtering & Windowing
The ability to ask real questions of the data. Not just “show me Catan” but “show me cooperative games for exactly 4 players that play in under 90 minutes at medium weight, excluding space-themed games.” Six orthogonal filter dimensions that compose with boolean logic across hundreds of thousands of games.
This is the feature that does not exist anywhere today. BGG has no multi-dimensional filter. No board game service lets you query by effective player count with expansions included. The OpenTabletop specification makes this possible.
Pillar 3: Statistical Foundation
Board game data is rich – millions of ratings, weight votes, player count polls accumulated over two decades – but today it is locked inside formats that make real analysis impossible. BGG’s “top games” rankings, weight scores, and player count recommendations are black boxes: you get a single number, not the underlying data. No data scientist, analyst, or statistician can do meaningful work with an undocumented XML endpoint that returns a pre-computed average.
OpenTabletop specifies data structures built for analysis. Player count polls are stored as per-count vote distributions, not a min/max range. Weight is a full vote distribution, not a single number. Community play times are statistical distributions with percentiles, not a box estimate. A conforming data source gives researchers actual material to work with: alternative ranking algorithms, trend analysis over time, complexity studies, recommendation engines – all become possible when the data is structured for analysis from the ground up.
A Taste of What This Enables
Imagine it is game night. You have 4 people, about 90 minutes, and your group prefers medium-weight strategy games. You own Ticket to Ride: Europe with the Europa 1912 expansion. One person does not like horror themes.
With a conforming OpenTabletop server, this is a single API call:
POST /games/search HTTP/1.1
Content-Type: application/json
{
"players": 4,
"playtime_max": 90,
"weight_min": 2.0,
"weight_max": 3.5,
"mechanics": ["route-building"],
"theme_not": ["horror"],
"effective": true,
"sort": "rating_desc",
"limit": 20
}
The effective: true flag means the search considers expansion combinations. Ticket to Ride: Europe’s box says 30-60 minutes, but community-reported play times for 4 players with Europa 1912’s expanded ticket set average closer to 70 minutes – still under the 90-minute cap. A conforming server knows this because the data model tracks how expansions modify effective play time. It uses community-reported times, not the publisher’s box estimate, so the results reflect how the game plays for the community of players who log their sessions – often a closer match to your experience than publisher estimates, especially for experienced groups.
No other board game API can answer this query today.
Where OpenTabletop Fits
OpenTabletop is a specification, not a database. This is an important distinction. MusicBrainz is both a standard and a hosted database – it defines what music metadata looks like and operates the canonical instance. OpenTabletop defines only the standard. Anyone can build and host a conforming implementation with their own data, their own community, and their own infrastructure.
This follows a pattern that has worked across the software industry:
| Standard | Domain | What it defines | Who implements it |
|---|---|---|---|
| POSIX | Operating systems | System call interface | Linux, macOS, FreeBSD |
| SQL | Databases | Query language | PostgreSQL, MySQL, SQLite |
| ActivityPub | Social media | Federation protocol | Mastodon, Lemmy, Pixelfed |
| Schema.org | Web data | Structured vocabulary | Google, Bing, search engines |
| FHIR | Healthcare | Health record format | Hospital systems worldwide |
| iCalendar | Scheduling | Event data format | Google Calendar, Outlook, Apple |
| OpenTabletop | Board games | Data model + API contract | Any board game platform |
The pattern is always the same: define the interface, let implementations compete on quality. The standard creates interoperability – an app built against one conforming server works against any other. The implementations create value – different communities can curate their own data, optimize for their own audience, and still speak the same language.
This is why the project ships schemas, vocabularies, and sample data rather than a running server. The specification is the product. A Japanese board game community, a German hobbyist database, a university research project, and a commercial collection tracker can all implement the same spec and produce interoperable data. The standard is the commons; the implementations are the marketplace.
Project Status
OpenTabletop is in the specification phase. The project is defining:
- The OpenAPI 3.2 specification document – the canonical source of truth
- This documentation, which explains the design rationale and data model
- Architecture Decision Records (ADRs) that capture key choices
- A governance model for community-driven evolution
The project provides schemas, controlled vocabularies, sample data, and implementer guidance – everything a developer needs to build a conforming server or client in the language of their choice.
The specification is developed in the open under dual licensing: Apache 2.0 for code, CC-BY-4.0 for the specification and documentation. Contributions are welcome – see the Governance Model and Getting Started guide.
Pillar 1: Standardized Data Model
The data model is the foundation of OpenTabletop. It defines every entity, relationship, and data type in the specification. The goal is a schema rigorous enough for a relational database, expressive enough to capture the full complexity of board game metadata, and stable enough that implementations can rely on it for years.
Entity-Relationship Overview
erDiagram
Game ||--o{ GameRelationship : "source"
Game ||--o{ GameRelationship : "target"
Game }o--o{ Mechanic : "has"
Game }o--o{ Category : "belongs to"
Game }o--o{ Theme : "themed as"
Game }o--o{ Family : "part of"
Game }o--o{ Person : "designed/illustrated by"
Game }o--o{ Organization : "published by"
Game ||--o{ PlayerCountPoll : "has votes"
Game ||--o{ PropertyModification : "modified by"
Game ||--o{ Identifier : "known as"
GameRelationship {
uuid id
uuid source_game_id
uuid target_game_id
enum relationship_type
}
Game {
uuid id
string slug
string name
enum type
int year_published
int min_players
int max_players
int min_playtime
int max_playtime
int community_min_playtime
int community_max_playtime
float weight
float rating
}
Person {
uuid id
string slug
string name
enum role
}
Organization {
uuid id
string slug
string name
enum type
}
Mechanic {
uuid id
string slug
string name
}
Category {
uuid id
string slug
string name
}
Theme {
uuid id
string slug
string name
}
Family {
uuid id
string slug
string name
}
PlayerCountPoll {
uuid game_id
int player_count
int best_votes
int recommended_votes
int not_recommended_votes
}
PropertyModification {
uuid id
uuid expansion_id
uuid base_game_id
string property
string modification_type
string value
}
Identifier {
uuid game_id
string source
string external_id
}
ExpansionCombination {
uuid id
uuid base_game_id
int min_players
int max_players
int min_playtime
int max_playtime
float weight
}
Game ||--o{ ExpansionCombination : "effective with"
ExpansionCombination }o--o{ Game : "includes expansions"
Entity Summary
| Entity | Description |
|---|---|
| Game | The core entity. Represents a base game, expansion, standalone expansion, promo, accessory, or fan expansion. See Game Entity. |
| GameRelationship | Typed, directed edges between games: expands, reimplements, contains, requires, recommends, integrates_with. See Game Relationships. |
| PropertyModification | How a single expansion changes a single property of its base game (e.g., “+2 max players”). See Property Deltas. |
| ExpansionCombination | Pre-computed effective properties for a specific set of expansions combined with a base game. See Property Deltas. |
| Mechanic | A controlled vocabulary term describing a game mechanism (e.g., “deck-building”, “worker-placement”). See Taxonomy. |
| Category | A controlled vocabulary term for game classification (e.g., “strategy”, “party”, “war”). See Taxonomy. |
| Theme | A controlled vocabulary term for thematic setting (e.g., “fantasy”, “space”, “historical”). See Taxonomy. |
| Family | A named grouping of related games (e.g., “Catan”, “Pandemic Legacy”). See Taxonomy. |
| Person | A designer, artist, or other credited individual. See People & Organizations. |
| Organization | A publisher, manufacturer, or distributor. See People & Organizations. |
| PlayerCountPoll | Community vote data for each supported player count. See Player Count Model. |
| Identifier | Cross-reference IDs linking to external systems (BGG, Frosthaven app, etc.). See Identifiers. |
Design Principles
Explicit over implicit. Every relationship is a first-class entity with a type discriminator, not an implied association.
Dual-source data. Wherever community perceptions differ from publisher-stated values, both are captured. Publisher-stated play time and community-reported play time are separate fields, not averaged into one. Both sources carry their own biases – see Data Provenance & Bias.
Combinatorial awareness. The data model does not just store “this expansion exists.” It stores how that expansion changes the base game’s properties, and it supports pre-computed combinations for sets of expansions. This is what makes effective mode filtering possible.
Controlled vocabulary with governance. Mechanics, categories, and themes are not free-text tags. They are managed terms with slugs, definitions, and an RFC process for additions. This prevents the fragmentation problem where “deck building” and “deckbuilding” and “deck-building” are three different tags.
Stable identifiers. Every entity has a UUIDv7 (time-sortable, globally unique) and a human-readable slug. External cross-references (BGG IDs, etc.) are stored as structured Identifier entities, not ad-hoc fields.
Game Entity
The Game entity is the core of the data model. Every board game, expansion, promo, and accessory is represented as a Game with a type discriminator that indicates its role.
Fields
| Field | Type | Required | Description |
|---|---|---|---|
id | UUIDv7 | yes | Primary identifier, time-sortable |
slug | string | yes | URL-safe human-readable identifier (e.g., twilight-imperium) |
name | string | yes | Primary display name |
type | enum | yes | Game type discriminator (see below) |
description | string | no | Full text description |
year_published | integer | no | Year of first publication |
min_players | integer | no | Publisher-stated minimum player count |
max_players | integer | no | Publisher-stated maximum player count |
min_playtime | integer | no | Publisher-stated minimum play time in minutes |
max_playtime | integer | no | Publisher-stated maximum play time in minutes |
community_min_playtime | integer | no | Community-reported minimum play time in minutes |
community_max_playtime | integer | no | Community-reported maximum play time in minutes |
weight | float | no | Community-voted complexity weight (1.0 - 5.0 scale) |
weight_votes | integer | no | Number of weight votes cast |
rating | float | no | Community average rating (1.0 - 10.0 scale) |
rating_votes | integer | no | Number of rating votes cast |
image_url | string | no | URL to the primary box art image |
thumbnail_url | string | no | URL to a thumbnail version of the box art |
created_at | datetime | yes | When this record was created (ISO 8601) |
updated_at | datetime | yes | When this record was last modified (ISO 8601) |
Type Discriminator
The type field classifies what kind of product this Game represents:
| Type | Description | Example |
|---|---|---|
base_game | A standalone game that can be played without any other product | Cosmic Encounter, War of the Ring, Wingspan |
expansion | Requires a base game to play; adds content or rules | Carcassonne: Inns & Cathedrals, Scythe: Invaders from Afar |
standalone_expansion | Part of a game family but playable on its own | Dominion: Intrigue, Star Realms: Colony Wars |
promo | A small promotional addition, typically a single card or tile | Azul: Special Factories Promo |
accessory | A non-gameplay product associated with a game (sleeves, organizer, playmat) | Terraforming Mars Playmat |
fan_expansion | Community-created content, not officially published | Gloomhaven: Homebrew Class Pack (fan) |
For detailed classification criteria, decision trees, and grey zone rules, see Entity Type Criteria.
Type Hierarchy
flowchart TD
G[Game Entity]
G --> BG[base_game]
G --> EX[expansion]
G --> SE[standalone_expansion]
G --> PR[promo]
G --> AC[accessory]
G --> FE[fan_expansion]
BG -.- note1["Playable alone"]
EX -.- note2["Requires a base game"]
SE -.- note3["Playable alone AND part of a family"]
PR -.- note4["Small add-on, usually a single component"]
AC -.- note5["Non-gameplay product"]
FE -.- note6["Community-created, unofficial"]
Dual Playtime Fields
The Game entity carries two sets of playtime fields, reflecting the reality that publisher estimates and actual play times often diverge significantly:
min_playtime/max_playtime: What the publisher prints on the box. These tend to be optimistic and assume experienced players. A game listed as “60-90 minutes” often takes 120+ minutes for a first play.community_min_playtime/community_max_playtime: Derived from community-reported play logs. These reflect what players actually experience. See Play Time Model for details on how these are computed.
Both sets of fields are available for filtering. The playtime_source filter parameter controls which set is used. See Filter Dimensions.
Weight and Rating
Weight is a community-voted measure of complexity on a 1.0 to 5.0 scale:
| Range | Interpretation | Examples |
|---|---|---|
| 1.0 - 1.5 | Very light – minimal rules, suitable for non-gamers | Uno, Codenames |
| 1.5 - 2.5 | Light – simple rules with some decisions | Ticket to Ride, Azul |
| 2.5 - 3.5 | Medium – meaningful strategic depth | Wingspan, Everdell |
| 3.5 - 4.5 | Heavy – complex rules and deep strategy | Spirit Island, Terraforming Mars |
| 4.5 - 5.0 | Very heavy – steep learning curve, long playtime | Twilight Imperium, Mage Knight |
The weight_votes field indicates how many community members contributed to the weight score. A weight of 3.5 with 5,000 votes is far more reliable than 3.5 with 12 votes.
Rating is a community average on a 1.0 to 10.0 scale, with rating_votes tracking the sample size. The specification does not define a Bayesian average or geek rating equivalent – those are derived values that belong in application logic, not the data model. The raw average and vote count are the source data.
Example
{
"id": "01967b3c-5a00-7000-8000-000000000001",
"slug": "spirit-island",
"name": "Spirit Island",
"type": "base_game",
"description": "Powerful spirits have existed on this island...",
"year_published": 2017,
"min_players": 1,
"max_players": 4,
"min_playtime": 90,
"max_playtime": 120,
"community_min_playtime": 90,
"community_max_playtime": 150,
"weight": 3.89,
"weight_votes": 5127,
"rating": 8.31,
"rating_votes": 42891,
"image_url": "https://example.com/images/spirit-island.jpg",
"thumbnail_url": "https://example.com/images/spirit-island-thumb.jpg",
"created_at": "2026-01-15T10:00:00Z",
"updated_at": "2026-03-01T14:30:00Z"
}
Game Relationships
Games do not exist in isolation. An expansion extends a base game. A reimplementation shares mechanics with its predecessor. A big-box edition contains multiple products. The GameRelationship entity captures these connections as typed, directed edges.
GameRelationship Entity
| Field | Type | Required | Description |
|---|---|---|---|
id | UUIDv7 | yes | Primary identifier |
source_game_id | UUIDv7 | yes | The game this relationship originates from |
target_game_id | UUIDv7 | yes | The game this relationship points to |
relationship_type | enum | yes | The type of relationship (see below) |
ordinal | integer | no | Ordering hint for display (e.g., expansion release order) |
Relationship Types
| Type | Direction | Description | Example |
|---|---|---|---|
expands | expansion -> base | Source adds content to target; source requires target to play | Wingspan: European Expansion expands Wingspan |
reimplements | new -> old | Source is a new version of target with mechanical changes | Brass: Birmingham reimplements Brass |
contains | collection -> item | Source physically includes target (big-box, compilation) | Dominion: Big Box contains Dominion and Dominion: Intrigue |
requires | dependent -> dependency | Source cannot be used without target (stronger than expands) | 7 Wonders: Cities Anniversary Pack requires both 7 Wonders and 7 Wonders: Cities |
recommends | game -> game | Source suggests target as a companion (non-binding) | A solo variant fan expansion recommends the base game’s organizer insert |
integrates_with | game <-> game | Source and target can be combined for a unified experience | Star Realms integrates_with Star Realms: Colony Wars |
Directionality
All relationships are stored as directed edges from source_game_id to target_game_id. For symmetric relationships like integrates_with, both directions are stored:
- Star Realms ->
integrates_with-> Star Realms: Colony Wars - Star Realms: Colony Wars ->
integrates_with-> Star Realms
This allows querying from either side without special-casing.
Case Study: The Spirit Island Family Tree
Spirit Island is an excellent example of the relationship model’s expressiveness. It has standard expansions, a standalone expansion, and dependency chains:
flowchart TD
SI["Spirit Island<br/><i>base_game</i>"]
BC["Branch & Claw<br/><i>expansion</i>"]
JE["Jagged Earth<br/><i>expansion</i>"]
NI["Nature Incarnate<br/><i>expansion</i>"]
HSI["Horizons of Spirit Island<br/><i>standalone_expansion</i>"]
P1["Promo Pack 1<br/><i>promo</i>"]
P2["Promo Pack 2<br/><i>promo</i>"]
FF["Feather & Flame<br/><i>compilation</i>"]
BC -->|expands| SI
JE -->|expands| SI
NI -->|expands| SI
P1 -->|expands| SI
P2 -->|expands| SI
FF -->|expands| SI
FF -->|contains| P1
FF -->|contains| P2
HSI -.->|integrates_with| SI
style SI fill:#1976d2,color:#fff
style HSI fill:#7b1fa2,color:#fff
style BC fill:#388e3c,color:#fff
style JE fill:#388e3c,color:#fff
style NI fill:#388e3c,color:#fff
style P1 fill:#f57c00,color:#fff
style P2 fill:#f57c00,color:#fff
style FF fill:#e65100,color:#fff
Key observations from this graph:
- Branch & Claw, Jagged Earth, and Nature Incarnate are
expansiontype games withexpandsrelationships pointing to Spirit Island. They require the base game. - Horizons of Spirit Island is a
standalone_expansion. It has anintegrates_withrelationship to Spirit Island (bidirectional) but does not have anexpandsrelationship, because it does not require the base game. - Promo Packs are
promotype games withexpandsrelationships. They add individual spirits or scenarios. Feather & Flame is acompilationthatcontainsboth Promo Pack 1 and Promo Pack 2 – the individual packs were difficult to obtain, so they were re-released as a single product. It alsoexpandsSpirit Island directly.
Querying Relationships
Get all expansions for a game
GET /games/spirit-island/relationships?type=expands&direction=inbound
Returns all games where target_game_id is Spirit Island and relationship_type is expands – i.e., everything that expands Spirit Island.
{
"data": [
{
"source_game_id": "01912f4c-7e3a-7b1a-8c5d-9f0e1a2b3c4d",
"target_game_id": "01912f4c-8a2b-7c3d-9e4f-0a1b2c3d4e5f",
"relationship_type": "expands",
"metadata": {
"adds_spirits": 2,
"adds_power_cards": 22
},
"_links": {
"source": { "href": "/games/spirit-island", "title": "Spirit Island" },
"target": { "href": "/games/spirit-island-branch-and-claw", "title": "Spirit Island: Branch & Claw" }
}
},
{
"source_game_id": "01912f4c-7e3a-7b1a-8c5d-9f0e1a2b3c4d",
"target_game_id": "01912f4c-9b3c-7d4e-af5a-1b2c3d4e5f6a",
"relationship_type": "expands",
"metadata": {
"adds_spirits": 10,
"adds_power_cards": 52
},
"_links": {
"source": { "href": "/games/spirit-island", "title": "Spirit Island" },
"target": { "href": "/games/spirit-island-jagged-earth", "title": "Spirit Island: Jagged Earth" }
}
},
// ... Nature Incarnate, Promo Pack 1, Promo Pack 2, Feather & Flame
],
"_links": {
"self": { "href": "/games/spirit-island/relationships?type=expands&direction=inbound" }
}
}
Get the full family tree
GET /games/spirit-island/relationships?depth=2
Returns all relationships within 2 hops of Spirit Island, enabling reconstruction of the full family graph.
{
"data": [
{
"source_game_id": "01912f4c-7e3a-7b1a-8c5d-9f0e1a2b3c4d",
"target_game_id": "01912f4c-8a2b-7c3d-9e4f-0a1b2c3d4e5f",
"relationship_type": "expands",
"_links": {
"source": { "href": "/games/spirit-island", "title": "Spirit Island" },
"target": { "href": "/games/spirit-island-branch-and-claw", "title": "Spirit Island: Branch & Claw" }
}
},
// ... other expands relationships ...
{
"source_game_id": "01912f4c-7e3a-7b1a-8c5d-9f0e1a2b3c4d",
"target_game_id": "01912f4c-bd5e-7f6a-cb7c-3d4e5f6a7b8c",
"relationship_type": "integrates_with",
"_links": {
"source": { "href": "/games/spirit-island", "title": "Spirit Island" },
"target": { "href": "/games/horizons-of-spirit-island", "title": "Horizons of Spirit Island" }
}
},
{
"source_game_id": "01912f4c-ea8b-7c9d-fe0f-6a7b8c9d0e1f",
"target_game_id": "01912f4c-ce6f-7a7b-dc8d-4e5f6a7b8c9d",
"relationship_type": "contains",
"_links": {
"source": { "href": "/games/spirit-island-feather-and-flame", "title": "Spirit Island: Feather & Flame" },
"target": { "href": "/games/spirit-island-promo-pack-1", "title": "Spirit Island: Promo Pack 1" }
}
},
// ... depth=2 traversal includes relationships of related games
],
"_links": {
"self": { "href": "/games/spirit-island/relationships?depth=2" }
}
}
Get what a game requires
GET /games/spirit-island-branch-and-claw/relationships?type=expands&direction=outbound
Returns the single relationship: Branch & Claw expands Spirit Island.
{
"data": [
{
"source_game_id": "01912f4c-8a2b-7c3d-9e4f-0a1b2c3d4e5f",
"target_game_id": "01912f4c-7e3a-7b1a-8c5d-9f0e1a2b3c4d",
"relationship_type": "expands",
"metadata": {
"adds_spirits": 2,
"adds_power_cards": 22
},
"_links": {
"source": { "href": "/games/spirit-island-branch-and-claw", "title": "Spirit Island: Branch & Claw" },
"target": { "href": "/games/spirit-island", "title": "Spirit Island" }
}
}
],
"_links": {
"self": { "href": "/games/spirit-island-branch-and-claw/relationships?type=expands&direction=outbound" }
}
}
Case Study: Brass Reimplementations
Reimplementation captures when a game is rebuilt with significant changes but shares a lineage. The Brass family spans nearly two decades:
flowchart LR
B["Brass<br/><i>(2007)</i>"]
AI["Age of Industry<br/><i>(2010)</i>"]
BL["Brass: Lancashire<br/><i>(2018)</i>"]
BB["Brass: Birmingham<br/><i>(2018)</i>"]
BP["Brass: Pittsburgh<br/><i>(2026)</i>"]
AI -->|reimplements| B
BL -->|reimplements| B
BB -->|reimplements| B
BP -.->|reimplements| B
style B fill:#757575,color:#fff
style AI fill:#43a047,color:#fff
style BL fill:#1976d2,color:#fff
style BB fill:#1976d2,color:#fff
style BP fill:#1976d2,stroke-dasharray: 5 5
Key observations from this graph:
- Brass (2007) – Martin Wallace’s original design, set in Lancashire during the Industrial Revolution. All subsequent entries reimplement this game.
- Age of Industry (2010) – Wallace’s own abstracted reimplementation with variable maps, predating the 2018 relaunch. A distinct design direction (green) from the later Roxley series.
- Brass: Lancashire (2018) – a refined re-release of the original Brass by Roxley Games. Same Lancashire setting, updated rules and art. Despite sharing a setting with the 2007 original, it is a distinct game entry with a
reimplementsrelationship. - Brass: Birmingham (2018) – a new map with distinct economic strategies, also by Roxley. Paired with Lancashire as the 2018 relaunch.
- Brass: Pittsburgh (2026) – upcoming entry in the Roxley series (dashed arrow indicates unreleased).
All five are distinct games linked by reimplements, capturing mechanical lineage without implying compatibility or dependency.
Why not treat these as versions of one game? On BoardGameGeek, the original Brass (2007) has no separate entry – it exists only as a “Version” under Brass: Lancashire. This collapses a meaningful design distinction: the 2007 and 2018 games have different rules, different component sets, and different ratings communities. OpenTabletop models them as separate game entities connected by
reimplements, preserving the historical lineage while keeping each game’s data (ratings, polls, weight) independent.
Property Deltas & Combinations
Expansions do not just add content – they change the properties of the base game. Invaders from Afar does not just add factions to Scythe; it raises the maximum player count from 5 to 7, extends play time at higher counts, and nudges the complexity weight upward with two new asymmetric factions. The property delta system captures these changes as structured, queryable data.
This is what makes effective mode filtering possible. Without it, you could only filter games by their base properties. With it, you can ask “what games support 6 players when I include expansions?” and get answers.
Three Layers of Data
Layer 0: Edition Deltas
Before expansion deltas are considered, the system accounts for edition differences. The same game may exist in multiple printings or editions – a revised edition might change the player count, adjust component counts that affect play time, or rebalance mechanics that shift the complexity weight. Edition deltas capture these differences relative to the canonical edition (the reference edition whose properties match the Game entity’s top-level values).
Key properties of edition deltas:
- Applied before expansion deltas in the resolution pipeline. The edition provides the adjusted base that expansions then modify.
- One edition at a time – unlike expansions, there is no combinatorial explosion. A game session uses exactly one edition.
- Deltas are relative to the canonical edition. If the 2017 first printing is canonical and the 2020 revised edition changes max play time from 120 to 90 minutes, the delta is -30 minutes.
- Optional. Many games have only one edition or no tracked edition differences. When no edition data exists, the canonical (top-level) properties are used directly.
The is_canonical flag on GameEdition marks which edition’s properties match the Game entity’s top-level values. The EditionDelta schema captures the property differences for non-canonical editions. See ADR-0035 for the full design rationale.
Layer 1: PropertyModification (Individual Deltas)
A PropertyModification records how a single expansion changes a single property of its base game.
| Field | Type | Required | Description |
|---|---|---|---|
id | UUIDv7 | yes | Primary identifier |
expansion_id | UUIDv7 | yes | The expansion that causes this change |
base_game_id | UUIDv7 | yes | The base game being modified |
property | string | yes | Which property is changed (e.g., max_players, weight, max_playtime, min_age) |
modification_type | enum | yes | How the property is changed: set, add, multiply |
value | string | yes | The new value or delta (interpreted based on modification_type) |
Modification types:
set– Replace the property value entirely. “Max players becomes 6.”add– Add to the existing value. “Max playtime increases by 30 minutes.”multiply– Multiply the existing value. Used rarely, mainly for scaling factors.
Layer 2: ExpansionCombination (Expansion Set-Level Effects)
An ExpansionCombination records the effective properties when a specific set of expansions is combined with a base game. This handles non-linear interactions: adding Invaders from Afar and The Rise of Fenris together does not simply stack their individual deltas. The combination has its own tested, community-verified properties.
| Field | Type | Required | Description |
|---|---|---|---|
id | UUIDv7 | yes | Primary identifier |
base_game_id | UUIDv7 | yes | The base game |
expansion_ids | UUIDv7[] | yes | The set of expansions included (order irrelevant) |
min_players | integer | no | Effective minimum player count |
max_players | integer | no | Effective maximum player count |
min_playtime | integer | no | Effective minimum play time |
max_playtime | integer | no | Effective maximum play time |
weight | float | no | Effective complexity weight |
top_at | integer[] | no | Player counts with rating above high threshold for this combination |
recommended_at | integer[] | no | Player counts with rating above moderate threshold for this combination |
min_age | integer | no | Effective recommended minimum age |
Three-Tier Resolution
When the system needs to determine the effective properties of a base game with a set of expansions, it follows a three-tier resolution strategy:
flowchart TD
Q["Query: Scythe + Invaders from Afar + The Rise of Fenris"]
T1{"Explicit<br/>ExpansionCombination<br/>exists?"}
T2{"Individual<br/>PropertyModifications<br/>exist?"}
T3["Fall back to<br/>base game properties"]
Q --> T1
T1 -->|Yes| R1["Use ExpansionCombination<br/>properties directly"]
T1 -->|No| T2
T2 -->|Yes| R2["Apply delta sum:<br/>base + sum of deltas"]
T2 -->|No| T3
R1 -.- N1["Highest confidence.<br/>Community-verified data<br/>for this exact combo."]
R2 -.- N2["Medium confidence.<br/>Assumes deltas stack<br/>linearly."]
T3 -.- N3["Lowest confidence.<br/>Expansion effects<br/>unknown."]
style R1 fill:#388e3c,color:#fff
style R2 fill:#f57c00,color:#fff
style T3 fill:#d32f2f,color:#fff
Tier 1: Explicit combination. If an ExpansionCombination record exists for exactly this set of expansions with this base game, use its properties. This is the most accurate: someone has verified “Scythe + IFA + Fenris supports 1-7 players at weight 3.52.”
Tier 2: Delta sum. If no explicit combination exists but individual PropertyModification records exist for each expansion, sum the deltas. For set modifications, the last one wins (by expansion release date). For add modifications, sum the values. This is a reasonable approximation but may not capture non-linear interactions.
Tier 3: Base fallback. If no modification data exists at all, use the base game’s properties unchanged. The expansion’s effects are simply unknown.
The API response includes a resolution_tier field so consumers know the confidence level of the effective properties they received.
Scythe: Full Worked Example
Here is the complete property delta data for Scythe and its expansions:
Base Game Properties
| Property | Value |
|---|---|
| min_players | 1 |
| max_players | 5 |
| top_at | [4] |
| recommended_at | [3, 4, 5] |
| weight | 3.45 |
| min_playtime | 115 |
| max_playtime | 115 |
| min_age | 14 |
Individual PropertyModifications
Invaders from Afar (2016):
| Property | Type | Value | Effect |
|---|---|---|---|
| max_players | set | 7 | Two new factions support up to 7 players |
| weight | set | 3.44 | BGG weight slightly lower than base 3.45 (see weight bias note below) |
| min_playtime | set | 90 | Minimum play time drops from 115 to 90 with more players |
| max_playtime | set | 140 | Play time extends at higher player counts |
The Wind Gambit (2017):
| Property | Type | Value | Effect |
|---|---|---|---|
| max_players | set | 7 | Airships support higher player counts |
| weight | set | 3.41 | Weight slightly decreases (airship module streamlines endgame) |
| min_playtime | set | 70 | Airship abilities can accelerate game end |
| max_playtime | set | 140 | Extended ceiling at higher player counts |
The Rise of Fenris (2018):
| Property | Type | Value | Effect |
|---|---|---|---|
| weight | set | 3.42 | BGG weight lower than base 3.45 (see weight bias note below) |
| min_playtime | set | 75 | Campaign episodes can be shorter than standard games |
| max_playtime | set | 150 | Full campaign episodes run longer |
| min_age | set | 12 | Age recommendation lowered from 14+ to 12+ |
Fenris does not change player count (still 1-5). It restructures the game into an 8-episode campaign and lowers the recommended age from 14+ to 12+ – the campaign’s guided structure makes the game more accessible to younger players. Its BGG weight (3.42) is slightly lower than the base game (3.45), following the same bias pattern as other expansions.
Encounters (2018):
| Property | Type | Value | Effect |
|---|---|---|---|
| max_players | set | 7 | Encounter cards work at expanded player counts |
| min_playtime | set | 90 | Minimum play time drops from 115 to 90 |
Encounters replaces combat events with a deck of narrative encounter cards. Its BGG weight of 2.71 reflects the encounter card content rated in isolation. Additional PropertyModifications may apply but are omitted here for brevity.
Scythe also has 14 promo packs that add factions, encounters, and modules. These are omitted from this worked example – the four major expansions above are sufficient to demonstrate the property delta system.
Why are all expansion weights lower than the base game? Every Scythe expansion has a lower BGG weight than the base game (3.45): Invaders from Afar is 3.44, The Wind Gambit is 3.41, Fenris is 3.42, and Encounters is 2.71. This does not mean expansions simplify the game. It reflects selection bias – the people who rate expansion weights on BGG are experienced players who have already internalized the base game’s complexity. They rate the marginal difficulty the expansion adds from their veteran perspective, not the total combined experience a new player would face. This is why explicit
ExpansionCombinationrecords are valuable: they capture the curated, community-verified complexity of the combined experience rather than relying on individually biased ratings that undercount total weight.
ExpansionCombinations (Explicit)
Scythe + Invaders from Afar:
| Property | Value |
|---|---|
| min_players | 1 |
| max_players | 7 |
| top_at | [4] |
| recommended_at | [3, 4, 5, 6] |
| weight | 3.44 |
| min_playtime | 90 |
| max_playtime | 140 |
| min_age | 14 |
Scythe + The Wind Gambit:
| Property | Value |
|---|---|
| min_players | 1 |
| max_players | 7 |
| top_at | [4] |
| recommended_at | [3, 4, 5] |
| weight | 3.43 |
| min_playtime | 70 |
| max_playtime | 140 |
| min_age | 14 |
Scythe + The Rise of Fenris:
| Property | Value |
|---|---|
| min_players | 1 |
| max_players | 5 |
| top_at | [3, 4] |
| recommended_at | [2, 3, 4, 5] |
| weight | 3.50 |
| min_playtime | 75 |
| max_playtime | 150 |
| min_age | 12 |
Scythe + Invaders from Afar + The Wind Gambit:
| Property | Value |
|---|---|
| min_players | 1 |
| max_players | 7 |
| top_at | [4, 5] |
| recommended_at | [3, 4, 5, 6] |
| weight | 3.46 |
| min_playtime | 70 |
| max_playtime | 140 |
Scythe + Invaders from Afar + The Rise of Fenris:
| Property | Value |
|---|---|
| min_players | 1 |
| max_players | 7 |
| top_at | [3, 4] |
| recommended_at | [2, 3, 4, 5, 6] |
| weight | 3.52 |
| min_playtime | 75 |
| max_playtime | 150 |
Scythe + Invaders from Afar + The Wind Gambit + The Rise of Fenris:
| Property | Value |
|---|---|
| min_players | 1 |
| max_players | 7 |
| top_at | [3, 4, 5] |
| recommended_at | [2, 3, 4, 5, 6] |
| weight | 3.55 |
| min_playtime | 70 |
| max_playtime | 150 |
Notice how multi-expansion combinations produce different results than summing individual deltas. The top_at list with Fenris includes 3 players – something no individual expansion achieves, because the campaign structure is particularly engaging at smaller counts. The Wind Gambit’s reduced min_playtime of 70 minutes persists through combinations – airship abilities that accelerate the endgame work regardless of other expansions present. And the weight with all major expansions reaches 3.55, above any individual expansion’s weight, reflecting the cumulative cognitive load of managing factions, airships, and campaign modules simultaneously. These non-linear effects are why explicit ExpansionCombination records exist.
Resolution in Action
If someone queries “Scythe + Invaders from Afar + The Rise of Fenris”:
- Check for an explicit
ExpansionCombinationwith exactly{IFA, Fenris}. It exists with weight 3.52, 1-7 players, 75-150 min. Tier 1.
If instead someone queries “Scythe + Invaders from Afar + The Rise of Fenris + Encounters”:
- Check for an explicit
ExpansionCombinationwith exactly{IFA, Fenris, Encounters}. None exists. - Encounters has
PropertyModificationrecords (max_players: set 7,min_playtime: set 90). Apply delta sum: max_players remains 7 (already set by IFA), min_playtime remains 75 (Fenris sets it lower than Encounters’ 90). Tier 2 – the result is a reasonable approximation, but non-linear interactions between the three expansions are not captured.
Data Quality
Property delta data is community-contributed and curated. The specification defines the schema; the data itself comes from:
- Publisher information (max player count with an expansion is often printed on the box)
- Community play reports (play time with expansions)
- Community voting (weight with expansions)
- Curator verification (explicit combination records reviewed by maintainers)
The resolution_tier field in API responses provides transparency about data quality, letting consumers decide how much to trust effective-mode results.
Taxonomy
Board game metadata relies on three classification vocabularies: mechanics, categories, and themes. These are controlled vocabularies – curated lists of terms with stable identifiers, not free-text tags. A game is tagged with zero or more entries from each vocabulary through many-to-many join relationships.
Why Controlled Vocabularies
The board game community has a fragmentation problem. “Deck building” and “deckbuilding” and “deck-building” and “Deck Building” are four different tags on various platforms, all meaning the same thing. Free-text tagging guarantees this kind of drift. Controlled vocabularies prevent it by defining each term exactly once with a canonical slug.
Every taxonomy term has:
| Field | Type | Description |
|---|---|---|
id | UUIDv7 | Primary identifier |
slug | string | URL-safe canonical name (e.g., deck-building) |
name | string | Human-readable display name (e.g., “Deck Building”) |
description | string | Definition of what this term means |
parent_id | UUIDv7 | Optional parent for hierarchical terms |
Mechanics
A mechanic describes how you interact with the game – the systems and structures that create the gameplay experience. These are objective, observable features of a game’s design.
Examples:
| Slug | Name | Description |
|---|---|---|
deck-building | Deck Building | Players construct their own play deck during the game |
worker-placement | Worker Placement | Players assign tokens to limited action spaces |
area-control | Area Control | Players compete for dominance over map regions |
cooperative | Cooperative | All players work together against the game system |
dice-rolling | Dice Rolling | Random outcomes determined by dice |
hand-management | Hand Management | Strategic decisions about which cards to play, hold, or discard |
drafting | Drafting | Players select items from a shared pool in turn order |
engine-building | Engine Building | Players construct systems that generate increasing returns |
hidden-roles | Hidden Roles | Players have secret identities affecting their objectives |
trick-taking | Trick Taking | Players play cards to win rounds based on rank and suit rules |
Mechanics can be hierarchical. deck-building might have children like pool-building (a variant using tokens instead of cards) and bag-building (drawing from a bag instead of a deck).
Categories
A category describes what kind of experience the game provides – its genre classification. Categories are more subjective than mechanics but still follow defined criteria.
Examples:
| Slug | Name | Description |
|---|---|---|
strategy | Strategy | Emphasis on long-term planning and tactical decisions |
party | Party | Designed for large groups with social interaction focus |
family | Family | Accessible rules suitable for mixed-age groups |
war | Wargame | Simulates military conflict with detailed combat systems |
abstract | Abstract | No theme; pure mechanical interaction (e.g., Chess, Go) |
thematic | Thematic | Theme is deeply integrated into mechanics and narrative |
economic | Economic | Focuses on resource management, trading, and market dynamics |
puzzle | Puzzle | Players solve logical challenges |
dexterity | Dexterity | Requires physical skill (flicking, stacking, balancing) |
legacy | Legacy | Game state permanently changes across sessions |
Themes
A theme describes the setting or subject matter of the game – its narrative and aesthetic wrapper. Themes are the most subjective vocabulary but still benefit from controlled terms.
Examples:
| Slug | Name | Description |
|---|---|---|
fantasy | Fantasy | Magic, mythical creatures, medieval-inspired settings |
space | Space | Outer space, space exploration, science fiction |
historical | Historical | Based on real historical events or periods |
horror | Horror | Dark, frightening, or supernatural themes |
nature | Nature | Wildlife, ecology, natural environments |
civilization | Civilization | Building and managing societies across eras |
pirates | Pirates | Seafaring, piracy, naval adventure |
trains | Trains | Rail networks, train operations, railway building |
mythology | Mythology | Based on mythological traditions (Greek, Norse, etc.) |
post-apocalyptic | Post-Apocalyptic | Survival in a world after societal collapse |
Families
A family groups games that share a brand, universe, or lineage but are not necessarily related by GameRelationship edges. Families are looser than relationships – they capture “these games are part of the same franchise” without implying mechanical dependency.
Examples:
| Slug | Name | Description |
|---|---|---|
catan | Catan | All games in the Catan universe |
pandemic | Pandemic | All Pandemic variants and legacy editions |
ticket-to-ride | Ticket to Ride | All Ticket to Ride maps and editions |
exit-the-game | EXIT: The Game | The EXIT series of escape room games |
18xx | 18xx | The family of railroad stock-trading games |
A game can belong to multiple families. Pandemic Legacy: Season 1 belongs to both pandemic and legacy-games.
RFC Process for New Terms
Adding a new mechanic, category, or theme is a specification change. It follows the RFC governance process:
-
Proposal. A contributor submits an RFC with the proposed term, slug, name, description, and justification. The RFC must explain why the term is not covered by existing vocabulary and provide at least three published games that would use it.
-
Discussion. The RFC is open for community comment for a minimum of 14 days. Feedback focuses on whether the term is distinct enough, whether the name is clear, and whether the proposed slug follows conventions.
-
Decision. The BDFL (or steering committee, after transition) approves, requests changes, or rejects the RFC. Approved terms are added to the next minor version of the specification.
-
Aliasing. If an existing term is found to be ambiguous or too broad, it can be deprecated and split into more specific terms. The old slug becomes an alias that maps to the new terms, preserving backward compatibility.
Slug Conventions
- Lowercase, hyphen-separated:
deck-building, notdeckBuildingordeck_building - Canonical slugs use the most common English term:
cooperative, notco-operative - Avoid abbreviations unless universally understood:
rpgis acceptable,wrkr-plcmntis not - Maximum 50 characters
Canonical English slugs serve as interoperability keys – they ensure that a Japanese implementation and a German implementation refer to the same mechanic with the same identifier. Implementations surface these slugs to users in their own language via display names and translations: the slug worker-placement is the API key, but the UI shows “ワーカープレイスメント” in Japanese or “Arbeiterplatzierung” in German. The slug is for machines; the display name is for humans.
Taxonomy Classification Criteria
This document provides the decision framework for classifying game attributes into the three OpenTabletop vocabulary types: mechanics, categories, and themes. Use this guide when proposing taxonomy additions via RFC, reviewing contributions, or importing data from external sources.
The Three Vocabulary Types
| Type | Question it answers | Example |
|---|---|---|
| Mechanic | How do you interact with the game? | Worker placement, dice rolling, deck building |
| Category | What kind of experience is it? | Euro, party, cooperative, dungeon crawler |
| Theme | What is it about? | Fantasy, pirates, trains, horror |
Decision Tree
Use this flowchart when classifying a proposed term:
flowchart TD
START["Does this term describe<br/>HOW you interact with the game?"]
START -->|Yes| OBS["Is it an observable, objective<br/>system or rule mechanism?"]
START -->|No| WHAT["Does it describe WHAT<br/>the game is about?<br/>(setting, subject, tone)"]
OBS -->|Yes| MECH["✅ MECHANIC<br/>(e.g., worker placement,<br/>dice rolling, auction)"]
OBS -->|No| DESIGN["Is it a design philosophy<br/>or genre classification?"]
DESIGN -->|Yes| CAT["✅ CATEGORY<br/>(e.g., euro, ameritrash,<br/>party game)"]
DESIGN -->|No| REEVAL["⚠️ Re-evaluate --<br/>may be too vague<br/>for the taxonomy"]
WHAT -->|Yes| THEME["✅ THEME<br/>(e.g., fantasy, pirates,<br/>trains, horror)"]
WHAT -->|No| EXPTYPE["Does it describe WHAT KIND<br/>of experience the game provides?"]
EXPTYPE -->|Yes| CAT2["✅ CATEGORY<br/>(e.g., cooperative,<br/>legacy, campaign)"]
EXPTYPE -->|No| META["❌ Does not belong in taxonomy<br/>-- may be game metadata<br/>(publisher, year, component list)"]
Worked Examples
Clear cases
- “Deck building” – You physically build a deck during play. Observable system with specific rules. -> Mechanic.
- “Euro” – Describes a cluster of design choices (low luck, indirect conflict, resource conversion). Not a single mechanism. -> Category.
- “Fantasy” – Describes the setting. -> Theme.
- “Trains” – What the game is about. -> Theme.
- “Party game” – What kind of experience (large group, social, quick). -> Category.
Grey zone cases
-
“Cooperative” – This is both a mechanic AND a category. The mechanic
cooperativedescribes the system (shared win condition, game-as-opponent). The categorycooperative-gamedescribes the experience type (playing together). Both entries exist with distinct definitions and distinct slugs (cooperativevscooperative-game). -
“Legacy” – Both a mechanic and a category. The mechanic
legacydescribes the system (permanent component modification). The categorylegacy-gamedescribes the format (finite lifecycle, sealed content). Both entries exist. -
“Horror” – A theme, not a category. “Horror” describes what the game is about (dark, frightening setting), not what kind of experience it provides. A worker-placement game about haunted houses is still a euro with a horror theme, not a “horror game” in the categorical sense.
-
“Miniatures” – A theme describing a component-centric hobby aspect, not a mechanic (miniatures don’t change how you play) and not a category (miniatures games span all design philosophies).
Grey Zone Rules
When a term sits at a boundary between vocabulary types:
-
Both mechanic and category: Acceptable when the mechanic describes a system and the category describes an experience. Use distinct slugs and definitions. The mechanic entry goes in
mechanics.yaml; the category entry goes incategories.yaml. Cross-reference in definitions. -
Category vs. theme boundary: Prefer theme unless the term describes a distinct design philosophy with defining mechanical characteristics. “Fantasy” is a theme (setting). “Euro” is a category (design philosophy). “Dungeon crawler” is a category (distinct gameplay loop with defining characteristics).
-
Components are not mechanics: “Cards,” “dice,” and “miniatures” are components. “Card drafting,” “dice rolling,” and “measurement movement” are mechanics – they describe how you use those components.
-
Scale test: A proposed term must apply to at least 10 published games to warrant inclusion in the controlled vocabulary. Narrower terms belong in game-level metadata, not the taxonomy.
-
Overlap test: If a proposed term cannot be distinguished from an existing term in one sentence, it should be merged with the existing term (possibly as a synonym).
Weight Scale Calibration
The OpenTabletop weight scale ranges from 1.0 to 5.0 and is anchored to reference games as comparative examples. Weight is a community perception of rules complexity and strategic depth – not an intrinsic property of the game and not a measure of game quality. The same game may be perceived as lighter by experienced players and heavier by newcomers. See Data Provenance & Bias for a deeper discussion of why all community metrics are population-dependent.
| Weight | Label | Anchor Games | Characteristics |
|---|---|---|---|
| 1.0 | Trivial | Candy Land, Chutes and Ladders | No meaningful decisions, pure randomness |
| 1.5 | Light | Uno, Sorry! | Minimal decisions, simple mechanics |
| 2.0 | Light-Medium | Ticket to Ride, Sushi Go! | Clear decisions, learnable in one session |
| 2.5 | Medium | Carcassonne, Pandemic | Meaningful strategy, multiple viable approaches |
| 3.0 | Medium-Heavy | Terraforming Mars, Wingspan | Significant decision space, interconnected systems |
| 3.5 | Heavy-Medium | Spirit Island, Brass: Birmingham | Complex interlocking systems, rewards experience |
| 4.0 | Heavy | Twilight Imperium, Agricola | Deep strategy, long rules explanation |
| 4.5 | Very Heavy | Mage Knight, War of the Ring | Extensive rules, many subsystems |
| 5.0 | Extreme | The Campaign for North Africa, ASL | Maximum complexity, simulation-level detail |
Calibration rule: When assigning weight, find the two anchor games that bracket the target game’s perceived complexity. The weight should fall between those anchors. Cross-reference with BGG’s community weight rating – significant divergence (>0.5) is worth investigating, as it may indicate a different voter population, a genuinely unusual game, or that the anchor comparison is misleading for this particular design.
RFC Reviewer Checklist
When evaluating a proposed taxonomy addition:
- Decision tree: Does the term pass the mechanic/category/theme flowchart?
- Scale test: Does it apply to 10+ published games?
- Overlap test: Is it distinguishable from existing terms in one sentence?
- Slug format: Lowercase, hyphenated, max 50 characters?
- Definition: Precise enough to distinguish from related terms?
- Examples: At least 3 canonical examples provided?
- BGG mapping: BGG IDs provided if a mapping exists?
- Cross-vocabulary check: If it exists in another vocabulary type (mechanic vs. category), are both entries justified with distinct definitions?
Entity Type Classification Criteria
This document provides the decision framework for classifying board game products into the six OpenTabletop entity types and for determining when a product should be a new entity versus an edition of an existing one. Use this guide when proposing new entities via RFC, reviewing data contributions, importing data from BGG, or resolving classification disputes.
The Six Entity Types
| Type | Question it answers | Defining characteristic | Example |
|---|---|---|---|
base_game | Can you play a complete game with just this product? | Standalone, independently designed | Catan, Spirit Island, Wingspan |
expansion | Does this require another product to play? | Adds content/rules to an existing game; not playable alone | Spirit Island: Branch & Claw, Scythe: Invaders from Afar |
standalone_expansion | Is this playable alone AND part of an existing game family? | Shares a game family identity and is self-contained | Dominion: Intrigue (2nd ed.), Star Realms: Colony Wars |
promo | Is this a small promotional gameplay addition? | Small component count, contains gameplay content, typically free/bundled | Azul: Special Factories Promo |
accessory | Is this a non-gameplay product associated with a game? | No gameplay content (sleeves, organizers, playmats, upgraded tokens) | Terraforming Mars Playmat, Wingspan Neoprene Mat |
fan_expansion | Is this unofficial community-created content? | Not published by or licensed by the game’s publisher | Gloomhaven: Homebrew Class Pack (fan) |
Primary Decision Tree
Use this flowchart when classifying a product:
flowchart TD
START["Is the product published by<br/>or licensed by the game's<br/>publisher or rights holder?"]
START -->|No| FE["✅ fan_expansion"]
START -->|Yes| GAMEPLAY["Does the product contain<br/>gameplay content?<br/>(rules, cards, tiles, scenarios,<br/>characters, win conditions)"]
GAMEPLAY -->|No| AC["✅ accessory"]
GAMEPLAY -->|Yes| STANDALONE["Can you play a complete,<br/>standalone game with<br/>ONLY this product?"]
STANDALONE -->|Yes| FAMILY["Was this product designed<br/>as part of an existing game family,<br/>sharing its core rules<br/>and designed to integrate?"]
STANDALONE -->|No| SCOPE["Is the product scope small?<br/>(typically 1-5 components,<br/>single card/tile/mini,<br/>no separate retail packaging)"]
FAMILY -->|Yes| SE["✅ standalone_expansion<br/>+ integrates_with relationship"]
FAMILY -->|No| BG["✅ base_game<br/>+ reimplements relationship<br/>if it's a new version"]
SCOPE -->|Yes| PR["✅ promo"]
SCOPE -->|No| EX["✅ expansion<br/>+ expands relationship"]
FE -.- note1["Community-created,<br/>unofficial content"]
AC -.- note2["Sleeves, organizers,<br/>playmats, upgraded tokens"]
SE -.- note3["Playable alone AND<br/>mixes with parent family"]
BG -.- note4["Independently designed<br/>or mechanically distinct"]
PR -.- note5["Small add-on, usually<br/>free or bundled"]
EX -.- note6["Requires a base game,<br/>adds content or rules"]
Entity vs Edition Decision Tree
When a new version of an existing game is released, use this flowchart to determine whether it should be a new entity or a new edition of the existing entity. See ADR-0035 for the edition data model.
flowchart TD
START2["Is a new version of an<br/>existing game being released?"]
START2 -->|No| PRIMARY["Classify using the<br/>primary decision tree above"]
START2 -->|Yes| CORE["Are the core rules<br/>fundamentally the same?"]
CORE -->|Yes| EDITION["✅ New GameEdition<br/>of the existing entity<br/>(use EditionDelta for<br/>property differences)"]
CORE -->|No| REPLACE["Does it share the same<br/>name/family and explicitly<br/>replace the original?"]
REPLACE -->|Yes| REIMPL["✅ New entity with<br/>reimplements relationship<br/>to the original"]
REPLACE -->|No| NEW["✅ New entity<br/>(classify using primary<br/>decision tree)"]
The core rules test: If a player who knows the old version could sit down at the new version and play correctly after a brief explanation of changes, they are editions of the same entity. If the rules teaching is essentially from scratch, they are different entities.
Worked Examples – Clear Cases
- Cosmic Encounter – Standalone game, independently designed. ->
base_game - Wingspan: European Expansion – Requires Wingspan to play, adds new birds/powers/end-of-round goals. ->
expansion - Dominion: Intrigue (2nd Ed.) – Playable alone, shares Dominion family, includes base cards, designed to mix with other Dominion sets. ->
standalone_expansion - Azul: Special Factories Promo – Single tile with new rules, small scope, convention handout. ->
promo - Terraforming Mars Playmat – No gameplay content, just a play surface. ->
accessory - Gloomhaven: Homebrew Class Pack – Not published by Cephalofair. ->
fan_expansion - Carcassonne (2014 new art edition) – Same core rules as original, updated art and minor rule clarifications. -> New
GameEdition, not a new entity.
Worked Examples – Grey Zone Cases
Grey Zone 1: promo vs accessory
Catan: Oil Springs – A scenario tile that adds new rules, a new resource type, and a new victory condition path. Even though it is a single tile, the presence of gameplay rules makes it a promo, not an accessory.
Contrast: “Catan Dice Tower” – a physical component with no new rules. -> accessory.
The test: Does the product add new rules, new decision points, new win conditions, or new player options? If yes, it contains gameplay content and is NOT an accessory.
Grey Zone 2: expansion vs standalone_expansion
Dominion: Intrigue (2nd Edition) – Can be played as a complete game by itself (it includes base cards) AND can be mixed with other Dominion sets. -> standalone_expansion.
Contrast: “Dominion: Seaside” cannot be played without base cards from Dominion or Intrigue. -> expansion.
The test: Open the box. Can you play a complete game tonight with nothing else? The presence of a “basic” or “starter” mode does not count – the full game must be playable.
Grey Zone 3: base_game vs standalone_expansion
Pandemic Legacy: Season 2 – Playable alone and shares the Pandemic family name. But it was designed as an independent game with its own rules and story arc, not as a variant of the original Pandemic. The family connection is branding, not mechanical dependency. -> base_game with a recommends relationship to Season 1.
Contrast: “Star Realms: Colony Wars” – same core rules as Star Realms, explicitly designed to be mixed with it, compatible card pool. -> standalone_expansion.
The test: A standalone_expansion must satisfy BOTH conditions: (a) self-sufficient, AND (b) mechanically designed to integrate with an existing game family (shared card pool, compatible components, explicit mix-and-match design). Games that merely share thematic branding but are mechanically independent are base_game.
Grey Zone 4: new entity vs new edition
Tigris & Euphrates (1997) vs Yellow & Yangtze (2018) – Different core mechanics (hexes vs squares, different scoring, different tile placement rules). Even though the designer describes Y&Y as a spiritual successor, the rules are substantially different. -> New entity (base_game) with reimplements relationship.
Contrast: “Carcassonne (2014 new art edition)” – same tile placement, same scoring, updated art and minor rule clarifications. -> New GameEdition with EditionDelta.
The test: Could a player of the original sit down and play correctly after a 2-minute delta explanation? If yes, it’s an edition. If the rules teaching is essentially from scratch, it’s a new entity.
Grey Zone 5: expansion vs fan_expansion
Root: The Clockwork Expansion – Published by Leder Games (the publisher of Root). -> expansion.
Contrast: A homebrew Root faction posted on BGG as a print-and-play PDF by a community member. -> fan_expansion.
The test: Is the content published by, or formally licensed by, the original game’s publisher or rights holder? If a fan expansion is later officially published, it transitions from fan_expansion to expansion (tracked via data correction workflow per ADR-0030).
Grey Zone 6: big promo vs small expansion
Azul: Special Factories Promo (1 tile) – Free convention handout, single component, no packaging. -> promo.
Contrast: “Wingspan: Oceania Expansion (95 cards, new boards, new food tokens)” – separately purchased, has its own box, multiple components, retail product. -> expansion.
The test: A promo is typically free or bundled, 1-5 components, no box, no separate retail SKU. An expansion is separately purchased, has its own packaging, multiple components, and a retail presence. The 5-component guideline is a soft threshold – a 3-card promo pack is a promo; a 3-card expansion with its own box, rulebook, and retail SKU is an expansion.
Grey Zone Rules
-
Gameplay content test (promo vs accessory): If the product adds new rules, new decision points, new win conditions, or new player options, it contains gameplay content and is NOT an
accessory. Upgraded components (metal coins, custom dice, playmats) that do not change gameplay are accessories. -
Self-sufficiency test (expansion vs standalone_expansion): Can a person who has never purchased the parent game play a complete game session with only this product? If yes ->
standalone_expansion. If no ->expansion. The presence of a “basic” or “starter” mode does not count – the full game must be playable. -
Family identity test (base_game vs standalone_expansion): A
standalone_expansionmust satisfy BOTH conditions: (a) self-sufficient (passes the self-sufficiency test above), AND (b) mechanically designed to integrate with or extend an existing game family (shared card pool, compatible components, explicit mix-and-match design). Games that merely share thematic branding but are mechanically independent arebase_gamewith appropriate relationships. -
Core rules test (new entity vs new edition): If the core gameplay loop, primary mechanics, and victory conditions are substantially the same, it is a new
GameEdition. If any of these are fundamentally different, it is a new entity. “Substantially the same” means a player of the original could play the new version after a brief explanation of changes. “Fundamentally different” means the game requires fresh teaching. -
Publisher authority test (expansion vs fan_expansion): Published by or formally licensed by the game’s publisher or rights holder ->
expansion(orpromo). All other community-created content ->fan_expansion. License status is determined by explicit publisher acknowledgment, not by marketplace availability. -
Scope test (promo vs expansion): Promos are small-scope additions (typically 1-5 components, no separate packaging, often distributed as convention freebies or Kickstarter stretch goals). Expansions have their own packaging, retail presence, and larger component count. When scope is ambiguous, prefer
promofor content distributed free/bundled andexpansionfor separately purchased content. -
When in doubt, prefer the more specific type. If a product could be either
base_gameorstandalone_expansion, and it clearly belongs to a game family, preferstandalone_expansion. The more specific classification carries more information.
BGG Migration Rules
BGG uses boardgame, boardgameexpansion, and boardgameaccessory as its type system – much coarser than OpenTabletop’s six types. These rules guide the import pipeline (ADR-0032).
| BGG type | Determination logic | OpenTabletop type |
|---|---|---|
boardgame | Default | base_game |
boardgame with reimplements link, same publisher | Core rules differ? Keep as base_game + reimplements. Core rules same? Flag for manual review as potential GameEdition. | base_game or GameEdition (manual review) |
boardgameexpansion with requires link | Standard expansion | expansion |
boardgameexpansion without requires link | Likely standalone – verify via self-sufficiency test | standalone_expansion (verify) |
boardgameexpansion with very low component count, no retail listing | Likely promo – verify via scope test | promo (verify) |
boardgameaccessory | Check if it contains gameplay content (rules in description) | accessory (or promo if gameplay content found) |
| Any BGG type, fan-created | Check publisher field against parent game publisher | fan_expansion if no publisher match |
Key migration principles
- No
standalone_expansionin BGG. These are listed as eitherboardgameorboardgameexpansionon BGG. During import, anyboardgameexpansionthat lacks arequireslink should be flagged for self-sufficiency review. - No promo/expansion distinction in BGG. Both are
boardgameexpansion. Use component count and distribution method as heuristics, but flag ambiguous cases for human review. - No edition system in BGG. When multiple BGG entries represent different editions of the same game (discoverable via
reimplementslinks between entries with very similar names), the import pipeline should create oneGameentity with multipleGameEditionrecords, keeping the highest-rated or most-voted entry as the canonical edition.
RFC Reviewer Checklist
When evaluating a proposed entity addition or type classification:
- Decision tree: Does the entity pass the primary decision tree flowchart?
-
Self-sufficiency verified: If typed as
base_gameorstandalone_expansion, has someone confirmed it is playable alone? -
Family identity checked: If typed as
standalone_expansion, is the game family identified and the integration mechanism described? - Edition check: Could this entity be an edition of an existing entity? Has the core rules test been applied?
-
Publisher authority: If typed as anything other than
fan_expansion, is the publisher or license confirmed? -
Scope check: If typed as
promo, is the component count small (typically 1-5) and the product non-retail? -
Relationships created: Are the appropriate typed relationships (ADR-0011) created alongside the entity? (e.g.,
expandsfor expansions,reimplementsfor re-releases,integrates_withfor standalone expansions) - BGG cross-reference: If a BGG ID exists, does the chosen type align with or justifiably differ from BGG’s classification?
- Grey zone documented: If the classification involves a grey zone judgment, is the rationale documented in the entity’s notes or the RFC discussion?
People & Organizations
Board games are created by people and published by organizations. The data model captures both with explicit, many-to-many relationships to games.
Person Entity
A Person represents an individual who contributed to a game’s creation.
| Field | Type | Required | Description |
|---|---|---|---|
id | UUIDv7 | yes | Primary identifier |
slug | string | yes | URL-safe name (e.g., cole-wehrle) |
name | string | yes | Display name (e.g., “Cole Wehrle”) |
created_at | datetime | yes | When this record was created |
updated_at | datetime | yes | When this record was last modified |
Game-Person Relationships
The association between a game and a person includes a role that specifies the nature of their contribution:
| Field | Type | Required | Description |
|---|---|---|---|
game_id | UUIDv7 | yes | The game |
person_id | UUIDv7 | yes | The person |
role | enum | yes | The contribution type |
Roles
| Role | Description | Example |
|---|---|---|
designer | Created the game’s rules and mechanics | Cole Wehrle designed Root |
artist | Created the visual art and graphic design | Kyle Ferrin illustrated Root |
developer | Refined and balanced the design (distinct from designer) | A playtesting lead who shaped the final product |
writer | Authored narrative or flavor text | The lore writer for a campaign-driven game |
graphic_designer | Designed the layout, iconography, and visual system | Distinct from the illustrator; focuses on usability |
A person can have multiple roles on the same game. A single individual might be both designer and developer, and those are recorded as two separate associations.
Many-to-Many
The relationship is fully many-to-many:
- A game can have multiple designers: Pandemic is designed by Matt Leacock (solo), but 7 Wonders Duel is designed by Antoine Bauza and Bruno Cathala.
- A person can design multiple games: Uwe Rosenberg designed Agricola, Caverna, A Feast for Odin, Patchwork, and dozens more.
flowchart LR
subgraph "Published by Leder Games"
RT["Root<br/><i>(2018)</i>"]
UW["Root: The Underworld<br/><i>(2020)</i>"]
OT["Oath<br/><i>(2021, IP sold 2026)</i>"]
end
subgraph "Published by Buried Giant Studios"
AR["Arcs<br/><i>(2024, IP purchased 2026)</i>"]
ONF["Oath: New Foundations<br/><i>(2026)</i>"]
end
subgraph People
CW["Cole Wehrle<br/><i>designer</i>"]
KF["Kyle Ferrin<br/><i>artist</i>"]
end
CW -->|designer| RT
CW -->|designer| UW
CW -->|designer| OT
CW -->|designer| AR
CW -->|designer| ONF
KF -->|artist| RT
KF -->|artist| UW
KF -->|artist| OT
KF -->|artist| AR
KF -->|artist| ONF
The same people (Cole Wehrle, Kyle Ferrin) appear on games across two different publishers. The person entities stay the same – only the game-organization relationships change. This is why people and organizations are modeled independently rather than nesting people under their publisher.
Organization Entity
An Organization represents a company involved in bringing a game to market.
| Field | Type | Required | Description |
|---|---|---|---|
id | UUIDv7 | yes | Primary identifier |
slug | string | yes | URL-safe name (e.g., leder-games) |
name | string | yes | Display name (e.g., “Leder Games”) |
type | enum | yes | Organization type (see below) |
website | string | no | Primary website URL |
country | string | no | ISO 3166-1 alpha-2 country code |
created_at | datetime | yes | When this record was created |
updated_at | datetime | yes | When this record was last modified |
Organization Types
| Type | Description |
|---|---|
publisher | The company that finances, produces, and distributes the game |
manufacturer | The company that physically produces the game components |
distributor | The company that handles logistics and retail placement |
licensor | The company that owns the IP being licensed for the game |
Game-Organization Relationships
| Field | Type | Required | Description |
|---|---|---|---|
game_id | UUIDv7 | yes | The game |
organization_id | UUIDv7 | yes | The organization |
role | enum | yes | publisher, manufacturer, distributor, or licensor |
region | string | no | ISO 3166-1 alpha-2 code for regional publishing rights |
year | integer | no | Year this organization’s edition was published |
A game commonly has multiple publishers for different regions:
- Root is published by Leder Games (US), Matagot (France), Schwerkraft-Verlag (Germany), and others.
The region field disambiguates which publisher is responsible for which market. The year field handles cases where publishing rights change over time.
Publisher Transitions
Publishing relationships are not permanent. Designers move between studios, and new expansions for an existing game family may ship under a different publisher. The data model must capture this without rewriting history.
Case study: Leder Games -> Buried Giant Studios. Cole Wehrle and Kyle Ferrin were the lead designer and artist at Leder Games (founded by Patrick Leder), where they created Root (2018), Oath (2021), and Arcs (2024). In January 2026, Wehrle announced that he and Ferrin were leaving to form Buried Giant Studios, joined by Drew Wehrle, Ted Caya, Josh Yearsley, and other longtime collaborators.
Crucially, Buried Giant purchased the rights to both Oath and Arcs from Leder Games outright – this is a full IP transfer, not a license. Root remains at Leder Games. The Oath: New Foundations Kickstarter (funded mid-2024) is now fulfilled by Buried Giant.
In the data model, this produces the following game-organization records:
| game | organization | role | year |
|---|---|---|---|
| Root | Leder Games | publisher | 2018 |
| Oath | Leder Games | publisher | 2021 |
| Arcs | Leder Games | publisher | 2024 |
| Oath: New Foundations | Buried Giant Studios | publisher | 2026 |
Note that Oath and Arcs retain their original Leder Games publisher records – that is historical fact. The IP transfer does not rewrite history; it means new products in those families are published by Buried Giant. The person records (Cole Wehrle, Kyle Ferrin) are unchanged across both eras – they are credited on Leder-era and Buried Giant-era games through the same person entities. This is a key reason why people are modeled independently from organizations: a designer’s body of work spans their entire career, not just their tenure at one company.
Querying
Get all games by a designer
GET /people/cole-wehrle/games?role=designer
Returns all games where Cole Wehrle is credited as designer – spanning both the Leder Games and Buried Giant Studios eras.
{
"data": [
{
"id": "01912f4c-a1b2-7c3d-8e4f-5a6b7c8d9e0f",
"slug": "root",
"name": "Root",
"type": "base_game",
"year_published": 2018,
"role": "designer",
"_links": {
"self": { "href": "/games/root", "title": "Root" }
}
},
{
"id": "01912f4c-b2c3-7d4e-9f5a-6b7c8d9e0f1a",
"slug": "oath",
"name": "Oath: Chronicles of Empire and Exile",
"type": "base_game",
"year_published": 2021,
"role": "designer",
"_links": {
"self": { "href": "/games/oath", "title": "Oath" }
}
},
{
"id": "01912f4c-c3d4-7e5f-af6b-7c8d9e0f1a2b",
"slug": "arcs",
"name": "Arcs",
"type": "base_game",
"year_published": 2024,
"role": "designer",
"_links": {
"self": { "href": "/games/arcs", "title": "Arcs" }
}
},
// ... Root: The Underworld Expansion, Root: The Marauder Expansion,
// Oath: New Foundations, Pax Pamir (Second Edition), etc.
],
"_links": {
"self": { "href": "/people/cole-wehrle/games?role=designer" }
}
}
Get all publishers for a game
GET /games/root/organizations?role=publisher
Returns all organizations with a publisher role for Root, disambiguated by region and year.
{
"data": [
{
"organization_id": "01913e5a-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"name": "Leder Games",
"slug": "leder-games",
"role": "publisher",
"region": "US",
"year": 2018,
"_links": {
"organization": { "href": "/organizations/leder-games", "title": "Leder Games" }
}
},
{
"organization_id": "01913e5a-2b3c-7d4e-9f5a-6b7c8d9e0f1a",
"name": "Matagot",
"slug": "matagot",
"role": "publisher",
"region": "FR",
"year": 2019,
"_links": {
"organization": { "href": "/organizations/matagot", "title": "Matagot" }
}
},
{
"organization_id": "01913e5a-3c4d-7e5f-af6b-7c8d9e0f1a2b",
"name": "Schwerkraft-Verlag",
"slug": "schwerkraft-verlag",
"role": "publisher",
"region": "DE",
"year": 2019,
"_links": {
"organization": { "href": "/organizations/schwerkraft-verlag", "title": "Schwerkraft-Verlag" }
}
}
// ... additional regional publishers
],
"_links": {
"self": { "href": "/games/root/organizations?role=publisher" }
}
}
Get all artists who worked on games in a family
GET /families/root/people?role=artist
Returns all people credited as artist on any game in the Root family.
{
"data": [
{
"id": "01913d4f-6f7a-7b8c-9d0e-1f2a3b4c5d6e",
"slug": "kyle-ferrin",
"name": "Kyle Ferrin",
"role": "artist",
"games": [
{ "slug": "root", "name": "Root" },
{ "slug": "root-the-underworld-expansion", "name": "Root: The Underworld Expansion" },
{ "slug": "root-the-marauder-expansion", "name": "Root: The Marauder Expansion" },
{ "slug": "root-the-homeland-expansion", "name": "Root: The Homeland Expansion" }
],
"_links": {
"self": { "href": "/people/kyle-ferrin", "title": "Kyle Ferrin" }
}
}
],
"_links": {
"self": { "href": "/families/root/people?role=artist" }
}
}
Players & Collections
Games don’t exist in a vacuum – they’re played by people. The specification models games, the people who create them (People & Organizations), and the people who play them. The Player entity captures who is behind the votes, ratings, play logs, and collection data that power every community-sourced metric in the specification.
Without Player data, every voter is interchangeable. A first-time casual gamer’s weight vote counts the same as a 20-year veteran’s. A voter who uses a 1-5 scale is averaged with one who uses 6-10. A rating from someone who played once is indistinguishable from someone who played 50 times. The Player entity makes these differences visible and actionable.
The Player Entity
| Field | Type | Required | Description |
|---|---|---|---|
id | UUIDv7 | yes | Primary identifier |
slug | string | yes | URL-safe username (e.g., jane-gamer-42) |
display_name | string | yes | Public display name |
declared_rating_scale | object | no | Voter’s preferred rating scale (e.g., {min: 1, max: 5} or {min: 1, max: 10}) – used for normalization (see Rating Model) |
experience_level | enum | no | Self-assessed: new_to_hobby, casual, experienced, hardcore |
created_at | datetime | yes | When this player joined |
The Player entity is deliberately minimal. Most of the richness comes from relationships – what games they own, play, and rate – not from fields on the entity itself.
Collection
A player’s collection captures their relationship to specific games:
| Field | Type | Required | Description |
|---|---|---|---|
player_id | UUIDv7 | yes | The player |
game_id | UUIDv7 | yes | The game |
status | enum | yes | owned, previously_owned, wishlist, want_in_trade, for_trade, preordered |
added_at | datetime | yes | When this status was set |
rating | float (1-10) | no | This player’s rating of this game (normalized to canonical scale) |
play_count | integer | no | Total times this player has played this game |
A player can have multiple statuses for the same game over time (owned → for_trade → previously_owned). The collection is a time-series of the player’s relationship with each game.
Collection as Demand Signal
Aggregate collection data produces the demand signals that publishers use for print run planning:
| Aggregate Metric | Derived From | Use Case |
|---|---|---|
owner_count | Count of status: owned | Install base – how many copies are in the wild |
wishlist_count | Count of status: wishlist | Forward demand – how many people want this game |
for_trade_count | Count of status: for_trade | Churn signal – how many owners want to get rid of it |
previously_owned_count | Count of status: previously_owned | Lifetime churn – how many have come and gone |
These aggregates are surfaced on the Game entity via ADR-0041. The Player entity is where the underlying per-player data lives.
Play Logs
Each play session is recorded as a structured log entry:
| Field | Type | Required | Description |
|---|---|---|---|
player_id | UUIDv7 | yes | The player |
game_id | UUIDv7 | yes | The game played |
date | date | yes | When the session occurred |
duration_minutes | integer | no | How long the session took |
player_count | integer | no | How many people played |
experience_level | enum | no | Player’s experience with this game at the time of play: first_play, learning, experienced, expert |
included_teaching | boolean | no | Whether rules teaching was part of this session |
expansion_ids | UUID[] | no | Which expansions were used |
Play logs are the foundation of:
- Community playtime data – aggregated into the Play Time Model
- Experience-bucketed playtime – ADR-0034
- Player count ratings – “how many times have you played at this count?” comes from the player’s logs
- Engagement metrics – plays-per-owner ratio on the Game entity
Taste Profile (Derived)
A player’s taste profile is computed from their collection, ratings, and play history – never self-declared. Self-declared preferences are unreliable (“I like all kinds of games” says the person whose collection is 90% heavy euros). Behavioral data is honest.
| Derived Attribute | Computed From | Description |
|---|---|---|
preferred_weight_range | Median weight of owned + highly-rated games | Where this player lives on the complexity spectrum |
preferred_player_count | Mode of player counts in play logs | What group size they typically play at |
preferred_mechanics | Most frequent mechanics in owned + highly-rated games | What interaction styles they gravitate toward |
preferred_themes | Most frequent themes in owned + highly-rated games | What settings/topics they enjoy |
collection_diversity | Entropy of mechanics/themes/weight in collection | How broad or narrow their taste is |
play_frequency | Plays per month over trailing 12 months | How actively they engage with the hobby |
These are derived, not stored as fields on the entity. Implementations compute them from the underlying data. The specification defines what they mean, not how to compute them – different algorithms may produce slightly different profiles.
Player Archetypes
Archetypes are clusters of players with similar behavioral profiles. They are derived from data, not self-declared labels. A player isn’t “labeled” a eurogamer – their collection, ratings, and play patterns cluster with other eurogamers.
Example archetypes (implementation-defined, not spec-mandated):
| Archetype | Behavioral Signals |
|---|---|
| Casual / Family | Low play frequency, light-medium weight preference, high player count, party/family mechanics |
| Eurogamer | Medium-heavy weight, low luck tolerance, engine-building/worker-placement mechanics |
| Thematic / Ameritrash | Theme-driven collection, higher luck tolerance, narrative/adventure mechanics |
| Wargamer | Heavy weight, hex-and-counter or area-control mechanics, historical themes |
| Solo gamer | Predominantly 1-player logs, cooperative mechanics, puzzle-like games |
| Collector | Large collection relative to play count, high acquisition rate |
| Social / Party | Light weight, high player count, social deduction / word game mechanics |
Archetypes enable corpus-based analysis: “What do players who own 50+ cooperative games think of this game at 2 players?” This is fundamentally different from – and more useful than – “What does the BGG average say?”
Why Players Matter for Data Quality
The Player entity directly addresses the data quality problems documented across the model docs:
| Problem | How Player Data Helps |
|---|---|
| Inconsistent rating scales (Rating Model) | declared_rating_scale on the Player provides the normalization key |
| Uncalibrated weight votes (Weight Model) | A player’s experience level and play count for the game contextualizes their weight vote |
| Self-selection bias (Data Provenance) | If we know the archetype distribution of voters for a game, we can quantify the bias |
| Complexity bias in ratings (Rating Model) | Ratings can be segmented by player archetype – “what do casual gamers rate this?” vs “what do hardcore eurogamers rate this?” |
| Pre-release brigading (Rating Model) | Play count = 0 flags votes from people who haven’t played the game |
| Experience-dependent perception (Weight Model) | The player’s play count for a specific game contextualizes whether this is a first-impression or a veteran assessment |
Corpus-Based Filtering
The Player entity enables a fundamentally new kind of filtering for Pillar 2:
Traditional filtering: “Show me cooperative games, weight 2.5-3.5, best at 2 players” – filters on game properties.
Corpus-based filtering: “Show me cooperative games rated above 4.0 by players whose collection is similar to mine” – filters on game properties as assessed by a specific player population.
This is the difference between “what does the crowd think?” and “what do people like me think?” The Player entity makes the second question answerable.
Example Queries
“Games rated highly by players like me”:
GET /games/search?mechanics=cooperative&weight_min=2.5&weight_max=3.5
&corpus=similar_to_player:01912f4c-a1b2-7c3d-8e4f-5a6b7c8d9e0f
&corpus_rating_min=4.0
{
"data": [
{
"slug": "pandemic",
"name": "Pandemic",
"weight": 2.42,
"average_rating": 7.6,
"corpus_rating": 8.9,
"corpus_match": {
"similar_players": 342,
"corpus_rating_count": 287
},
"_links": {
"self": { "href": "/games/pandemic" }
}
},
{
"slug": "the-crew-mission-deep-sea",
"name": "The Crew: Mission Deep Sea",
"weight": 2.07,
"average_rating": 8.1,
"corpus_rating": 8.7,
"corpus_match": {
"similar_players": 342,
"corpus_rating_count": 198
},
"_links": {
"self": { "href": "/games/the-crew-mission-deep-sea" }
}
}
// ...
],
"_links": {
"self": { "href": "/games/search?mechanics=cooperative&weight_min=2.5&weight_max=3.5&corpus=similar_to_player:01912f4c-a1b2&corpus_rating_min=4.0" }
}
}
Note corpus_rating (8.9) vs average_rating (7.6) for Pandemic – players with similar collections rate it significantly higher than the general population. This is the corpus signal: the undifferentiated crowd rates it 7.6, but people like you rate it 8.9.
“Top solo games according to solo gamers”:
GET /games/search?corpus=archetype:solo-gamer&top_at=1&sort=corpus_rating
{
"data": [
{
"slug": "mage-knight",
"name": "Mage Knight",
"weight": 4.28,
"average_rating": 8.1,
"corpus_rating": 9.2,
"corpus_match": {
"archetype": "solo-gamer",
"archetype_voters": 1847,
"corpus_rating_count": 1203
},
"player_count_ratings": {
"1": { "average_rating": 4.8, "rating_count": 1589 }
},
"_links": {
"self": { "href": "/games/mage-knight" }
}
}
// ...
]
}
The solo-gamer archetype rates Mage Knight at 9.2 vs the general population’s 8.1 – and the per-count rating at 1 player is 4.8/5. This is a game that the broader community likes, but solo gamers love.
These queries are aspirational – the specification defines the data model that makes them possible, but the query syntax and implementation are future RFC topics.
Privacy Considerations
Player data is sensitive. The specification defines these principles:
- Opt-in. Player profiles are created voluntarily. Anonymous voting remains possible – a vote without a linked Player entity is valid but lacks the context metadata.
- Aggregation without exposure. Archetype distributions, corpus-based ratings, and voter population analysis can be computed without exposing individual player identities or collections.
- Player controls their data. A player can delete their profile, which anonymizes their votes (the votes remain, the Player link is severed).
- No required disclosure. Fields like
declared_rating_scaleandexperience_levelare optional. Players provide what they’re comfortable with.
Relationship to Other Entities
flowchart LR
P["Player"]
G["Game"]
C["Collection Entry"]
PL["Play Log"]
R["Rating / Vote"]
P -->|owns/wishlists| C
C -->|references| G
P -->|logs plays of| PL
PL -->|for game| G
P -->|rates / votes on| R
R -->|for game| G
The Player is the hub connecting games to the community data about them. Every rating, weight vote, player count rating, playtime log, and collection status traces back to a Player – or to an anonymous voter whose data is valid but uncontextualized.
Rating Model
Game ratings are the most visible number in any board game database – and the most misunderstood. A rating of 8.3 does not mean a game is objectively “8.3 out of 10 good.” It means a self-selected population of voters produced that average. The OpenTabletop specification stores ratings as raw distributional data, exposing the inputs rather than a single opaque output.
How OpenTabletop Stores Ratings
The Game entity carries these rating fields:
| Field | Type | Description |
|---|---|---|
rating | float (0-10) | Arithmetic mean of all user ratings |
bayes_rating | float (0-10) | Bayesian average that regresses toward the global mean (Layer 4 implementation recommendation) |
rating_votes | integer | Total number of user ratings |
rating_distribution | integer[10] | Histogram: count of votes at each 1-10 bucket (ADR-0041) |
rating_stddev | float | Standard deviation of the distribution |
rating_confidence | float (0-1) | Spec-level confidence score (Layer 3) |
The rating and rating_votes are the source data. The bayes_rating is a derived value that implementations may compute using their own parameters (Layer 4). The rating_confidence is the spec-level trust signal (Layer 3). The rating_distribution histogram exposes the full shape of voter opinion.
BGG’s Bayesian Average (“Geek Rating”)
BoardGameGeek computes its rankings using a Bayesian average. BGG has never published the exact formula, but reverse-engineering analysis estimates it adds approximately 1,500 dummy votes each rated at 5.5 (the midpoint of the 1-10 scale) to every game’s actual votes:
geek_rating = (sum_of_real_ratings + 1500 × 5.5) / (real_vote_count + 1500)
Purpose: This prevents a game with two 10/10 ratings from outranking a game with 50,000 votes averaging 8.5. By pulling all games toward 5.5, the formula requires a large number of high ratings to climb the rankings.
Example: A game with 100 real votes averaging 9.0 gets a geek rating of ~5.72 (still pulled heavily toward 5.5). A game with 50,000 real votes averaging 8.5 gets a geek rating of ~8.41 (barely affected by the dummy votes). The dummy votes wash out as real votes accumulate.
Known Problems with the BGG Rating Model
1. Secret, Non-Reproducible Algorithm
The exact number of dummy votes, their value, and the definition of “regular voter” are not public. Reverse-engineering efforts estimate ~1,500 dummy votes at ~5.5 (with best-fit values ranging from 5.494 to 5.554 depending on methodology), but BGG has never confirmed any parameter, and they may change over time. A specification built on a secret algorithm is not a specification – it’s a black box.
2. Opaque Vote Scrubbing
BGG removes ratings it considers suspicious – likely from new accounts, inactive accounts, or patterns suggesting manipulation. The criteria are unpublished, which means the same raw data can produce different rankings depending on which votes BGG decides to include. This is understandable as an anti-manipulation measure but makes the ranking non-deterministic from an outside observer’s perspective.
3. Dummy Votes Don’t Distinguish New from Niche
A game with 50 votes might be new (and will accumulate more) or niche (and 50 votes is its steady state). The Bayesian average penalizes both equally. A niche wargame beloved by its small community and a recently-released party game both get pulled toward 5.5, but for entirely different reasons. The formula cannot distinguish these cases.
4. Inconsistent Scale Usage
The 1-10 scale is used inconsistently across voters:
- Some voters use the full range (1-10). Others effectively rate on a 5-10 scale, never rating below 5.
- Some voters use a 1-5 mental scale mapped onto BGG’s 1-10 range – their “5 out of 5” game gets a 5, not a 10. This makes them appear to rate everything extremely low compared to voters using the full range. A voter whose highest rating is a 5 and another whose lowest is a 6 are using incompatible scales, but their votes are averaged together as if they weren’t.
- Some voters only rate games they own (biasing upward). Others rate games they disliked and traded away.
- Some voters treat ratings as a personal ranking tool (their #1 game gets a 10, their #50 gets a 6). Others treat the scale as absolute.
- The result: a “7” from one voter is not comparable to a “7” from another. There is no calibration mechanism – BGG does not normalize ratings across voters or detect incompatible scale usage.
5. Complexity Bias
Dinesh Vatvani’s analysis demonstrates a significant correlation between game complexity (weight) and BGG rating. The heaviest games on BGG average roughly 2.5 points higher than the lightest games (regression slope ~0.63 on the 1-5 weight scale mapped to the 1-10 rating scale). This is not because complex games are objectively better – it’s because BGG’s voter population disproportionately values complexity. See Weight Model for deeper analysis.
6. Self-Selection Bias
Only people who care enough to rate games on BoardGameGeek – whether through the website, the BGG app, or third-party apps that sync plays and ratings back to BGG – are represented. This population skews toward experienced hobbyist gamers who prefer medium-to-heavy strategy games and are engaged enough in the hobby to track their plays and maintain ratings. Casual gamers, families, and non-English-speaking communities are underrepresented. The BGG top 100 is a popularity contest within a specific demographic, not a universal quality ranking.
OpenTabletop addresses this structurally: because the specification is language-agnostic and designed for multiple independent implementations, a Japanese board game community, a German community, or a Brazilian community can each run conforming servers with their own voting populations. Ratings from these communities reflect their preferences, not BGG’s English-speaking demographic. The same API contract serves all of them, and applications can query across communities or within a specific one. See Data Provenance & Bias.
OpenTabletop’s Approach
Input Contract
The rating model follows the specification’s Input Contract principles:
| Element | Rating-Specific Definition |
|---|---|
| Question | “Rate your overall experience with [Game]” |
| Scale | 1-10, with voter-declared scale preference supported (see Layer 1 below). Anchors: 1 = “terrible, would never play again”, 5 = “mediocre, take it or leave it”, 10 = “outstanding, a top game for me” |
| Context captured | Declared scale preference (e.g., “I use 1-5”), number of plays of this game, experience level |
| Transparency | “Your 4/5 is recorded as 8/10 on the canonical scale because you declared a 1-5 preference” |
The Four-Layer Model
OpenTabletop uses a four-layer model that addresses the fundamental problems with BGG’s approach:
Layer 1: Voter-Declared Scale
The specification captures each voter’s personal scale preference as metadata at vote time. A voter can declare “I rate on a 1-5 scale” or “I rate on a 1-10 scale” or “I only use the 5-10 range.” This declaration is the normalization key: a voter whose declared max is 5 has their 5 mapped to 10 on the canonical 1-10 scale.
This eliminates the “1-5 voter looks like they hate everything” problem at the source rather than trying to correct for it statistically. It also makes the normalization transparent to the voter (“your 4/5 is recorded as 8/10 on the canonical scale”).
Where declared scale data is unavailable (e.g., legacy BGG imports), implementations may fall back to statistical inference (see Item Response Theory below), but the spec’s native path is explicit declaration.
Layer 2: Raw Normalized Data
All aggregate statistics are computed from votes normalized to the canonical 1-10 scale:
average_rating– Arithmetic mean of normalized votes. No dummy votes, no scrubbing, no secret formula.rating_count– Sample size. Consumers assess reliability themselves.rating_distribution– Full 1-10 histogram revealing distribution shape:- Tight bell curve around 7-8 = broad consensus.
- Bimodal (peaks at 4 and 9) = polarizing game.
- Left-skewed = most like it, a few hate it.
- The shape tells a richer story than any average.
rating_stddev– Standard deviation quantifying voter agreement (0.8 = tight consensus, 2.5 = wild disagreement).
Layer 3: Confidence Score
A spec-defined confidence metric (0.0 to 1.0) that answers: “How much should you trust this rating?” Computed from:
- Sample size – Wilson-style penalty for small n. A game with 50 votes gets lower confidence than one with 50,000, even if both average 8.5.
- Distribution shape – Tight consensus (low std dev) increases confidence. Polarized distributions (high std dev, bimodal) decrease it.
- Deviation from global mean – Ratings far from the population mean require more evidence. A 9.5 average with 100 votes gets lower confidence than a 7.5 average with 100 votes, because extraordinary claims require extraordinary evidence.
The confidence formula is published, reproducible, and deterministic – any consumer can verify it. It replaces bayes_rating as the spec-level field, because confidence is a more honest signal than an opaque adjusted number.
Layer 4: Implementation-Recommended Ranking
For implementations that need to sort games (leaderboards, “top 100” lists), the specification recommends Bayesian scoring with a Dirichlet prior over the 10-point rating distribution:
Instead of BGG’s estimated approach (adding ~1,500 dummy votes all at a single value of ~5.5), the Dirichlet method assigns per-bucket prior votes across the full rating distribution. This is mathematically sound (the Dirichlet distribution is the conjugate prior for categorical data) and more expressive – the prior can encode “we expect a game to have a roughly normal distribution centered at 6” rather than collapsing all prior weight onto a single point.
score = Σ(utility[i] × (prior_votes[i] + actual_votes[i])) / Σ(prior_votes[i] + actual_votes[i])
Where utility[i] maps each rating bucket to a value (e.g., 1 through 10) and prior_votes[i] is the per-bucket prior. The spec documents reference parameters; implementations may tune their own.
This layer is implementation guidance, not a spec requirement. The spec guarantees Layers 1-3. Layer 4 is a recommendation for implementations that need ranking.
Statistical Models Considered
The following models were evaluated for the rating system. This analysis is documented to inform the commons group and future RFC discussions.
| Model | Best For | Verdict |
|---|---|---|
| Simple Bayesian Average (BGG) | Quick ranking with small samples | Useful but insufficient – single prior value, no confidence, no voter normalization |
| Wilson Score Interval | Binary up/down votes | Good for binary signals (SteamDB uses this), awkward for 1-10 ordinal scales without binarization |
| Dirichlet-Prior Bayesian | Star/numeric ratings | Best fit for this problem – handles ordinal scales natively, tunable per-bucket priors, simple computation |
| Item Response Theory (IRT) | Heterogeneous voter correction | Gold standard for separating rater bias from item quality, but requires hundreds of ratings per item and is computationally expensive. Aspirational for v2+ where declared scales are unavailable |
| Bradley-Terry / Crowd-BT | Pairwise comparisons | Not applicable – we don’t collect pairwise data |
| Glicko-2 / TrueSkill | Competitive skill estimation | Wrong domain – designed for sequential match outcomes, not independent quality assessments |
Anti-Gaming Considerations
A published, transparent formula is essential for a commons – but transparency creates a target for manipulation. The rating model must be designed to resist:
Fan brigading. Coordinated groups inflating or deflating ratings. Mitigations: account age requirements before rating, play log verification (must log at least one play before rating), weighting by voter history diversity, anomaly detection on sudden vote spikes.
Publisher self-promotion. Publishers or affiliates inflating their own games. Mitigations: the spec supports conflict-of-interest metadata – a voter’s relationship to the game’s publisher or designer can be declared or detected, and implementations can weight those votes differently (not discard them – transparency, not censorship).
Spite voting. Organized downvoting campaigns against a competing game. Same mitigations as brigading, plus the Dirichlet prior naturally bounds the impact – no single vote or small group of votes can dramatically shift a well-established rating.
Formula exploitation. Sophisticated actors optimizing voting patterns to maximize impact. The Dirichlet prior model is inherently robust here: each individual vote’s marginal impact decreases as total votes increase, and the per-bucket prior prevents extreme distributions from dominating with few votes.
The specification recommends that conforming implementations:
- Publish their scoring formula. Transparency is non-negotiable for a commons standard.
- Bound individual vote influence. No single vote should be able to move a game’s rating by more than a defined maximum percentage.
- Implement velocity limits. Maximum N ratings per account per time period.
- Flag, don’t silently discard. Suspicious patterns should be flagged transparently with reason codes – not secretly scrubbed as BGG does. The community should be able to audit moderation decisions.
Case Study: Pre-Release Brigading
In early 2026, an unreleased crowdfunded game announced the use of AI-generated art. The community response on BGG was immediate and extreme: organized 1-star voting as a protest against the publisher’s decision. This triggered a counter-response of defensive 10-star votes attempting to offset the damage. The result was a rating distribution that contains almost no quality signal:
| Rating | Votes | Distribution |
|---|---|---|
| 10 | 146 | ██████████ |
| 9 | 11 | █ |
| 8 | 7 | ▌ |
| 7 | 1 | ▏ |
| 6 | 1 | ▏ |
| 5 | 4 | ▎ |
| 4 | 0 | |
| 3 | 4 | ▎ |
| 2 | 11 | █ |
| 1 | 298 | ████████████████████ |
Key statistics: 479 total votes. Average: 4.10. Standard deviation: 4.16 – against a theoretical maximum of 4.5 for a 1-10 scale. This distribution is at 92% of maximum possible disagreement. 93% of all votes are at the two extremes (1 or 10); only 7% fall in the 2-9 range. The game has not been released – none of these votes reflect play experience.
What BGG reports: BGG displays the raw average (4.1) to users in the UI. Under the hood, the Bayesian average (~1,500 dummy votes at ~5.5) produces an estimated geek rating of approximately 5.16, which determines the game’s ranking position – but this number is never shown to users. A casual observer sees “4.1” and assumes it’s a low-rated game. Nothing in BGG’s interface signals that 93% of votes are political protest/counter-protest, not quality assessment. And the hidden ranking score of ~5.16 would place this unreleased, unplayed game alongside legitimately mediocre games that people have actually experienced.
What OpenTabletop’s model reports:
| Metric | Brigaded Game | Brass: Birmingham (healthy reference) |
|---|---|---|
| Raw average | 4.10 | 8.57 |
| Std deviation | 4.16 | 1.42 |
| Votes | 479 | 57,266 |
| BGG displayed | 4.1 (raw avg) | ~8.6 (raw avg) |
| BGG ranking score | ~5.16 (hidden Bayesian) | ~8.5 (hidden Bayesian) |
| Confidence score | 0.27 | 0.83 |
The confidence score of 0.27 (on a 0-1 scale) immediately signals: do not trust this rating. The high vote count (479) is not the problem – the sample size factor is fine (0.95). The problem is the distribution shape factor (0.07) – a std dev of 4.16 on a 1-10 scale is nearly indistinguishable from random noise at the extremes. The confidence score captures what BGG’s Bayesian average cannot: that a number can be statistically “precise” (small margin of error around 4.10) while being semantically meaningless (the votes don’t measure game quality).
What the input contract would add: If the specification requires “number of plays” as vote context metadata, every vote on this unreleased game would be flagged as a 0-play rating. Implementations could display these votes separately (“0-play ratings: 4.10 avg / 479 votes / confidence: 0.27”) from post-release play-based ratings, letting consumers see the protest signal without it contaminating the quality signal.
Detection heuristics: This pattern is detectable:
- Std deviation above 3.5 on a 1-10 scale (healthy games rarely exceed 2.5)
- More than 80% of votes at the two extremes (1 and 10)
- Sudden vote spike correlated with a news event rather than gradual accumulation
- Zero or near-zero play logs associated with the votes
Further Reading
- How Not To Sort By Average Rating (Evan Miller, 2009) – Why Wilson score intervals beat simple averages for binary ratings
- Bayesian Scoring of Ratings (Jules Jacobs, 2015) – Dirichlet-prior approach for star ratings, extending beyond binary
- Reverse Engineering the BoardGameGeek Ranking – Technical analysis of BGG’s Bayesian average formula
- Complexity Bias in BGG (Vatvani, 2018) – Quantitative analysis of the weight-rating correlation
- Debiasing the BoardGameGeek Ranking – Methodology for removing complexity bias from rankings
- Adjusted Board Game Geek Ratings (2025 update) – Updated interactive analysis
- Crowdsourcing with Difficulty: A Bayesian Rating Model for Heterogeneous Items (2024) – IRT-based approach for crowdsourced ratings
- Data Provenance & Bias – OpenTabletop’s philosophical foundation for handling subjective data
Weight Model
“Weight” is the board game community’s term for perceived complexity. It is the single most misunderstood metric in board game databases – treated as an objective property of a game when it is actually a perception that varies by voter, context, and community.
How OpenTabletop Stores Weight
The Game entity carries these weight fields:
| Field | Type | Description |
|---|---|---|
weight | float (1.0-5.0) | Community-voted average complexity |
weight_votes | integer | Number of votes contributing to the average |
The specification treats weight as a community perception metric, not an intrinsic game property. The weight_votes field is critical context: a weight of 3.5 with 5,000 votes is a stable signal; 3.5 with 12 votes is noise.
The 1.0-5.0 Scale
The weight scale uses anchor games as calibration reference points (see Taxonomy Criteria for the full table):
| Weight | Label | Anchor Games |
|---|---|---|
| 1.0 | Trivial | Candy Land, Chutes and Ladders |
| 2.0 | Light-Medium | Ticket to Ride, Sushi Go! |
| 3.0 | Medium-Heavy | Terraforming Mars, Wingspan |
| 4.0 | Heavy | Twilight Imperium, Agricola |
| 5.0 | Extreme | The Campaign for North Africa, ASL |
What Weight Actually Measures (And Doesn’t)
Weight conflates several distinct dimensions into a single number:
- Rules complexity – How many rules are there? How many exceptions? How long does the rulebook take to read?
- Strategic depth – How much does skill matter? How many meaningful decisions per turn?
- Decision density – How many choices does each turn present? How much do they interact?
- Cognitive load – How much state must a player track mentally? How far ahead must they plan?
- Fiddliness – How many physical components must be manipulated? How many bookkeeping steps per round?
- Game length – Longer games tend to be rated heavier, even if individual turns are simple.
A game like Twilight Imperium is heavy on nearly every dimension. But a game like Go has trivial rules complexity, zero fiddliness, and extreme strategic depth – yet gets a moderate weight rating because voters mentally average across all dimensions. The single-number weight rating cannot distinguish these profiles.
Known Problems with the Weight Model
1. Weight Is Perceptual, Not Objective
The same game receives genuinely different weight perceptions from different populations:
- An experienced strategy gamer who plays Mage Knight weekly might rate Terraforming Mars a 2.5 (“moderate – fewer subsystems than what I usually play”).
- A casual gamer encountering Terraforming Mars for the first time might rate it 4.5 (“one of the most complex games I’ve ever played”).
- Both assessments are valid within their reference frames. The aggregate average reflects the voter population, not the game.
2. No Calibration Mechanism
Unlike the rating scale (where everyone agrees “10 means I love it”), the weight scale has no universally understood anchors. The informal anchor games listed above help, but voters rarely consult them before voting. Most weight votes are intuitive, based on how a game felt relative to the voter’s personal experience – not an objective assessment against a calibrated scale.
3. Complexity Bias in Ratings
Dinesh Vatvani’s analysis quantifies a significant correlation between weight and BGG rating:
- Regression slope: ~0.63 – For each point increase in weight (1-5 scale), the average BGG rating increases by ~0.63 points (1-10 scale).
- Total effect: ~2.5 points – The heaviest games on BGG average roughly 2.5 rating points higher than the lightest games.
- This is not because complex games are better. It’s because BGG’s voter population disproportionately values complexity. Experienced hobbyists who rate games on BGG tend to prefer heavier games, and the same population votes on both weight and rating.
The practical effect: light games that are excellent for their audience (party games, family games, gateway games) are systematically underrated relative to heavy games. A brilliant party game like Codenames sits at 7.6 while a heavy strategy game of comparable design quality sits at 8.5+.
4. “The Tail of Spite”
Vatvani’s analysis identifies a cluster of old, mass-market games with both low weight and extremely low ratings: Monopoly (4.4), Candy Land (3.2), Snakes and Ladders (2.8). These games are rated by people who grew up with them and now rate them harshly from the perspective of experienced hobbyists. The ratings don’t reflect these games’ fitness for their intended audience (children, families, casual play) – they reflect the BGG community’s disdain for simplicity.
5. Self-Selected Voter Population
The people who rate weight on BGG are overwhelmingly experienced hobbyist gamers. Their perception of “how complex is this?” is calibrated against hundreds of games they’ve played. A casual gamer’s 4.0 rating and a hardcore gamer’s 2.5 rating for the same game are both “correct” within their frame – but only the hardcore gamer’s vote is likely to be recorded on BGG.
This means weight ratings are most accurate for the middle-to-heavy portion of the spectrum (2.5-4.5) where BGG’s voter population has the most experience. Very light games (1.0-2.0) and the extremes of heaviness (4.5-5.0) are less reliably rated because fewer voters have extensive experience at those ends.
6. Experience Modifies Perception
A game’s perceived weight decreases with experience. Spirit Island might feel like a 4.5 on first play and a 3.5 after twenty plays – but the voter only submits one number. When that number was submitted matters, and BGG doesn’t track whether a weight vote came from a first-time player or a veteran. The aggregate weight reflects an unknowable mix of experience levels.
OpenTabletop’s Approach
The specification acknowledges weight’s limitations while preserving its utility as the best available community signal for complexity:
-
Store the raw average and vote count. The
weightandweight_votesfields provide the signal and its sample size. Consumers can assess reliability. -
Expose the distribution where possible. The specification’s commitment to distributional data (see Data Provenance & Bias) means implementations are encouraged to store and expose weight vote distributions, not just averages. A bimodal weight distribution (many 2.0 votes and many 4.0 votes) signals that different player populations perceive the game very differently.
-
Don’t treat weight as absolute. The specification never says a game “is” a specific weight. It says the community rated it at that weight. Applications should present weight in context: “Rated 3.86 by 5,127 voters” rather than “Weight: 3.86.”
-
Anchor games provide calibration. The Taxonomy Criteria weight scale with anchor games gives voters and implementations a shared reference frame. When a voter considers whether Brass: Birmingham is a 3.5 or a 4.0, comparing it to the anchor games at each level produces more consistent results than an unaided gut feeling.
-
Consider debiasing for rankings. Implementations that rank games should consider complexity-bias correction – a simple linear regression that produces “complexity-agnostic” ratings. This surfaces excellent light games that the raw rankings bury.
Input Contract
The weight model follows the specification’s Input Contract principles. Because weight conflates multiple distinct dimensions (see What Weight Actually Measures above), the specification supports two input modes:
Quick Mode
A single composite rating for voters who want a fast, familiar experience:
| Element | Definition |
|---|---|
| Question | “How complex is [Game]?” |
| Scale | 1.0-5.0, with anchor games visible at input time (e.g., 1.0 = Candy Land, 2.5 = Carcassonne, 3.5 = Brass: Birmingham, 5.0 = Campaign for North Africa) |
| Context captured | Number of plays of this game, self-assessed experience level (new to hobby / casual / experienced / hardcore) |
| Transparency | “Your vote is recorded as-is. The aggregate reflects this community’s perception, not an intrinsic property of the game.” |
Detailed Mode (Dimensional Survey)
An optional decomposed survey where each dimension of “weight” is rated independently. This eliminates the “what does weight mean?” ambiguity by asking concrete, answerable questions:
| Dimension | Question | 1 (Low) | 5 (High) |
|---|---|---|---|
| Rules complexity | “How many rules and exceptions does [Game] have?” | Minimal rules, learned in minutes (e.g., Uno) | Extensive rulebook, many exceptions (e.g., ASL) |
| Strategic depth | “How much does skill matter vs luck?” | Mostly luck or randomness | Deep strategy, rewards experience |
| Decision density | “How many meaningful choices do you face each turn?” | One simple choice per turn | Many interacting decisions per turn |
| Cognitive load | “How much game state must you track mentally?” | Minimal – focus on your own position | Must track multiple players’ plans turns ahead |
| Fiddliness | “How much bookkeeping and component management?” | Almost none | Constant upkeep, tracking, and maintenance |
| Game length | “How does session length contribute to the sense of weight?” | Quick, light sessions (< 30 min) | Long, intensive sessions (3+ hours) |
The composite weight is computed as the mean of the dimensional ratings (or an implementation-defined weighted average). Implementations may choose to weight certain dimensions more heavily – for example, weighting strategic depth higher than fiddliness if their audience considers depth more important than busywork.
Why Dimensional Data Matters
Dimensional weight data enables queries that a single number cannot:
- “Show me games with high strategic depth but low fiddliness” – separates good complexity from tedious complexity.
- “Show me games with low rules complexity but high decision density” – finds elegant designs with simple rules and deep decisions (e.g., Go, Azul).
- “Show me games where cognitive load is high but rules complexity is low” – finds games that are easy to learn but hard to master.
A game like Go has trivial rules complexity (1/5), zero fiddliness (1/5), but extreme strategic depth (5/5) and high cognitive load (5/5). Its composite weight of ~3.0 looks “medium” – but the dimensional profile reveals it is anything but average. A game like Twilight Imperium scores 4-5 on every dimension – its 4.4 composite accurately represents uniform heaviness. The single number hides the profile; the dimensions reveal it.
Partial Responses
Voters may answer any subset of dimensions. A voter who answers 3 of 6 dimensions still provides useful signal. The composite is computed from available dimensions only, with the response count tracked per dimension for confidence assessment.
Backward Compatibility
Quick mode produces the same single-number weight that existing consumers expect. Implementations that support only quick mode are fully conforming. Detailed mode is additive – it enriches the data without breaking any existing interface.
Further Reading
- Complexity Bias in BGG (Vatvani, 2018) – Quantitative analysis of the weight-rating correlation
- Adjusted Board Game Geek Ratings (2025 update) – Interactive, updated debiased rankings
- Debiasing the BoardGameGeek Ranking – Methodology for removing complexity bias
- Weight: Depth vs. Complexity (BGG forum) – Community discussion of what weight actually measures
- Data Provenance & Bias – OpenTabletop’s philosophical foundation for handling subjective data
- Rating Model – Companion document on BGG’s rating system and its problems
Player Count Model
Player count is not a single number. A game that “supports 1-4 players” may be excellent at 2, good at 3, mediocre at 1, and actively bad at 4. The player count model captures this nuance through three layers: publisher range, community ratings per count, and effective range with expansions.
Publisher Range
The min_players and max_players fields on the Game entity store what the publisher prints on the box. These are factual – the publisher says the game supports this range – but they say nothing about quality.
Example: Terraforming Mars is listed as 1-5 players. This is accurate in the sense that the rules work for any count in that range – but it says nothing about quality. The solo mode is essentially a different game (beating a timer rather than competing), and 5-player games can run over three hours.
Community Player Count Ratings
The PlayerCountRating entity captures how the community rates the experience at each supported player count. Each voter independently rates each player count on a 1-5 scale (1 = poor, 5 = excellent). This produces a real numeric distribution per player count that can be analyzed with standard statistical tools.
| Field | Type | Description |
|---|---|---|
game_id | UUIDv7 | The game being rated |
player_count | integer | The specific player count being evaluated |
average_rating | float (1.0-5.0) | Mean community rating at this player count |
rating_count | integer | Number of votes at this player count |
rating_stddev | float | Standard deviation (consensus vs polarization) |
Key properties of this model:
- Independent ratings. A voter who thinks 3p and 4p are both excellent can rate both 5/5. No forced ranking.
- No overlapping categories. A single numeric scale has no ambiguity about where “good” ends and “great” begins – those are thresholds consumers choose.
- Standard statistics. Mean, median, std dev, percentiles, confidence intervals – all standard tools apply directly.
- Per-count distributions. Implementations are encouraged to store the full vote distribution (how many 1s, 2s, 3s, 4s, 5s) per player count, not just the average.
Example: Terraforming Mars Player Count Ratings
| Player Count | Avg Rating | Votes | Std Dev | Signal |
|---|---|---|---|---|
| 1 | 2.1 / 5 | 1,277 | 1.2 | Polarizing – solo mode divisive |
| 2 | 4.2 / 5 | 1,222 | 0.7 | Strong consensus – great at 2 |
| 3 | 4.7 / 5 | 1,407 | 0.5 | Tight consensus – widely considered the sweet spot |
| 4 | 3.6 / 5 | 1,246 | 1.0 | Good but opinions vary |
| 5 | 2.3 / 5 | 1,141 | 1.1 | Polarizing – game length is the concern |
From this data, a consumer can derive:
- Highest rated at 3 (4.7/5 with tight consensus at std dev 0.5).
- Well-rated at 2 and 4 (both above 3.5, the “good” threshold).
- Poorly rated at 1 and 5 (both below 2.5, suggesting these counts are not the intended experience).
- Polarization visible at 1p and 5p via high standard deviation – voters disagree, some love the solo mode while others don’t consider it a real game.
The specification stores the raw rating data, not derived labels. Different applications may use different thresholds: a hardcore strategy app might set “recommended” at 4.0+, while a family app might set it at 3.0+. The raw data enables any interpretation.
Player count rating data reflects the voting community’s experience and preferences. This community tends to be experienced hobbyist gamers whose priorities at different player counts – tolerance for downtime, game length, and complexity scaling – may differ from casual players, families, or groups new to the hobby. See Data Provenance & Bias.
BGG Legacy Data
For migration from BoardGameGeek (ADR-0032), the specification also supports the PlayerCountPollLegacy schema, which preserves BGG’s three-tier voting model (Best / Recommended / Not Recommended). This data is imported during migration and available via the API, but it is not the native model.
The three-tier model has known statistical limitations – overlapping categories, forced ranking, missing middle ground, and anchoring bias – documented in ADR-0043. Implementations may convert legacy three-tier data to approximate numeric ratings (e.g., Best -> 5, Recommended -> 3.5, Not Recommended -> 1.5) for unified querying, but should flag the source as "bgg_converted" for transparency.
Derived Fields
For convenience, the Game entity includes pre-computed derived fields based on the rating data:
| Field | Type | Description |
|---|---|---|
top_player_counts | integer[] | Player counts with average rating above a high threshold (e.g., 4.0+) |
recommended_player_counts | integer[] | Player counts with average rating above a moderate threshold (e.g., 3.0+) |
These thresholds are implementation-defined. The specification documents them as convenience fields – the raw per-count rating data is always available for custom analysis.
“Highly Rated at 2” vs “Supports 2”
This distinction – between factual support and community quality sentiment – is critical and is something no existing board game API captures well:
- “Supports 2” means the rules work with 2 players. This is a binary fact derived from
min_players <= 2 <= max_players. - “Highly rated at 2” means the community rates the 2-player experience well (e.g., above 4.0/5). This is a community assessment derived from per-count ratings.
- “Acceptable at 2” means the community considers 2 at least a reasonable experience (e.g., above 3.0/5). This is a softer threshold.
All three are independently filterable. When you search for games with players=4, you get games that support 4. When you search with top_at=4, you get games the community rates highly at 4. When you search with recommended_at=4, you get games where 4 is at least a decent experience. The specific threshold values are application-defined – the API provides the raw per-count ratings and lets consumers set their own cutoffs.
Effective Player Count with Expansions
When effective mode is enabled, player count filtering considers expansion combinations. The ExpansionCombination entity can override player count ranges and community ratings:
- Cosmic Encounter base: supports 3-5, highest rated at 5 (4.6/5)
- Cosmic Encounter + Cosmic Incursion: supports 3-6, highest rated at 5-6 (4.5+/5)
- Cosmic Encounter + Cosmic Incursion + Cosmic Conflict: supports 3-7, highest rated at 5-6 (4.4+/5)
- Cosmic Encounter + all expansions: supports 3-8, highest rated at 5-6 (4.3+/5)
Effective mode searches across all known combinations, so a query for players=7&effective=true would surface the Cosmic Encounter + Incursion + Conflict combination even though the base game only supports up to 5.
See Property Deltas & Combinations for how these effective properties are determined.
Beyond the Range
Community ratings sometimes include data for player counts outside the publisher range. A game listed as 2-4 players might have ratings for 1 player (via an unofficial solo variant) or 5 players (via a fan expansion or house rule). These ratings are stored if they exist but are clearly outside the publisher-stated range, and the API distinguishes them accordingly.
OpenTabletop’s Approach
Input Contract
The player count model follows the specification’s Input Contract principles:
| Element | Player Count-Specific Definition |
|---|---|
| Question | “Rate your experience playing [Game] at [N] players” (asked independently per count) |
| Scale | 1-5 (1 = poor experience at this count, 3 = acceptable, 5 = excellent) |
| Context captured | Number of plays at this specific player count, overall familiarity with the game |
| Transparency | “Each player count is rated independently. You can rate multiple counts equally – no forced ranking.” |
Known Limitations
The numeric per-count rating model addresses the major flaws of BGG’s three-tier system (see ADR-0043), but has its own considerations:
- Scale calibration. What does “3 out of 5” mean for a player count? The specification does not yet define anchor descriptions (unlike the weight scale). This is a future RFC topic.
- BGG data conversion. Converting three-tier BGG data to numeric ratings requires mapping assumptions (e.g., Best -> 5, Recommended -> 3.5, NR -> 1.5). Different mappings produce different results. Implementations should document their conversion formula.
- Voter adoption. The numeric model requires voters to learn a new interface. Implementations that also support the familiar BGG-style three-tier input as an alternative should convert those inputs to numeric values internally.
- Population bias. Regardless of the voting model, the voter population skews toward experienced hobbyist gamers. The rating data reflects their priorities (tolerance for downtime, strategic depth at each count), not a universal assessment. See Data Provenance & Bias.
Play Time Model
Play time is one of the most commonly misrepresented data points in board gaming. The publisher says “60 minutes,” but your first game took 3 hours. The data model addresses this with a dual-source approach: publisher-stated times and community-reported times as separate, independent fields.
Publisher-Stated Play Time
The min_playtime and max_playtime fields store what appears on the box, in minutes:
| Field | Type | Description |
|---|---|---|
min_playtime | integer | Publisher’s minimum play time in minutes |
max_playtime | integer | Publisher’s maximum play time in minutes |
These are factual records of what the publisher claims. They are useful for comparison across games (a “60-90 minute” game is in a different category than a “180-240 minute” game), but they should not be taken as accurate predictions of actual play time.
Why Publisher Times Are Optimistic
Publisher play time estimates are systematically biased toward lower numbers for several reasons:
- Marketing pressure. A shorter play time makes the game more appealing to a wider audience. “90 minutes” sounds more accessible than “2-3 hours.”
- Experienced players assumed. Publishers often time with their own playtest groups who know the game intimately. First-time players will be significantly slower.
- Setup and teardown excluded. The timer starts when the first turn begins, not when you open the box. For complex games, setup can add 15-30 minutes.
- Analysis paralysis not modeled. Real players think longer than ideal players, especially in strategy-heavy games.
- Player count variation ignored. Many publishers list a single time range that does not scale with player count, even when a 5-player game takes twice as long as a 2-player game.
Community-Reported Play Time
The community_min_playtime and community_max_playtime fields are derived from actual play logs submitted by community members:
| Field | Type | Description |
|---|---|---|
community_min_playtime | integer | Community-reported minimum play time in minutes |
community_max_playtime | integer | Community-reported maximum play time in minutes |
These represent the range within which most community-reported plays fall. The specification defines “most” as the 10th to 90th percentile of reported play times, excluding obvious data entry errors (plays under 5 minutes or over 24 hours for non-campaign games).
Data Source
Community play times come from logged plays where the player recorded a duration. Not all logged plays include duration, and the ones that do are self-reported, so there is inherent noise. With enough data points, the aggregate provides a more detailed picture than publisher estimates – though it still reflects the play patterns of people who log their games, who tend to be more experienced hobbyist gamers. See Data Provenance & Bias for more on how community data is shaped by who contributes it.
Example: Ark Nova
| Source | Min | Max |
|---|---|---|
| Publisher | 90 min | 150 min |
| Community | 120 min | 210 min |
The publisher says 90-150 minutes. The community data tells a different story: even experienced 2-player games rarely finish under 2 hours, and 4-player games with new players can exceed 3.5 hours. The publisher’s 90-minute lower bound assumes experienced players making fast decisions – a condition that rarely holds for a game with this much card variety and decision density.
Example: Gloomhaven
| Source | Min | Max |
|---|---|---|
| Publisher | 60 min | 120 min |
| Community | 90 min | 180 min |
The publisher’s optimistic “60-120 minutes” per scenario misses that most groups, especially in early scenarios with new characters, spend 90-180 minutes. Setup and teardown alone can take 20 minutes.
Filtering by Play Time
The filter dimensions support both sources:
playtime_min/playtime_max– Filter using whichever source is selected byplaytime_sourceplaytime_source=publisher– Use publisher-stated times (default)playtime_source=community– Use community-reported times
Why default to publisher times? Because publisher times are available for nearly every game, while community times require sufficient play log data. For less popular games, community data may not exist. Defaulting to publisher ensures the broadest coverage while allowing users who want accuracy to opt into community times.
Play Time with Expansions
Expansions frequently change play time. Adding content means more decisions, more setup, and longer games. The property delta system captures these changes:
- Base game publisher time: 90-120 min
- With expansion: 90-150 min (individual delta)
- With two expansions: 120-180 min (explicit combination)
When effective mode is enabled, play time filtering considers these expansion effects.
Experience-Adjusted Play Time
All playtime data – publisher-stated, community-reported, and expansion-modified – implicitly assumes experienced players. But a first play of Agricola takes roughly 60% longer than an experienced play – the card combinations, feeding mechanics, and action optimization are overwhelming on first encounter. The experience-adjusted playtime model (ADR-0034) addresses this by bucketing community play data by experience level.
Experience Levels
| Level | Description | Typical Multiplier |
|---|---|---|
first_play | Everyone is new to the game | ~1.5× |
learning | 1-3 prior plays, still referencing rules | ~1.25× |
experienced | 4+ plays, knows the rules well (baseline) | 1.0× |
expert | Optimized play, minimal downtime | ~0.85× |
How It Works
Community play logs include a self-reported experience level. The system aggregates these into per-level median and percentile times. Multipliers are derived per-game as median[level] / median[experienced].
Example: Agricola
| Level | Median | 10th pctl | 90th pctl | Reports |
|---|---|---|---|---|
| First play | 150 min | 110 min | 220 min | 278 |
| Learning | 120 min | 90 min | 165 min | 445 |
| Experienced | 95 min | 65 min | 130 min | 1,089 |
| Expert | 70 min | 50 min | 100 min | 231 |
Agricola’s first-play multiplier is 1.58× – a first-time group should budget nearly twice the box’s “30 minutes per player” estimate. The card combinations, feeding pressure, and action space are overwhelming on first encounter. The expert multiplier of 0.74× reflects that veteran players who have internalized the occupation/improvement combos and optimal action sequences can finish remarkably quickly.
Why Per-Game Multipliers Matter
Different games have fundamentally different experience curves:
- Party games (Codenames, Wavelength): Near-zero first-play penalty. Rules take 2 minutes to explain, and play speed barely changes with experience.
- Medium-weight euros (Wingspan, Everdell): Moderate first-play penalty (~1.3×). The rules are learnable in one session, but card familiarity speeds up experienced play.
- Heavy games (Agricola, Through the Ages): High first-play penalty (~1.5-2.0×). Complex interlocking systems, large decision trees, and frequent rules references extend first plays dramatically.
A global multiplier would be wrong for all three categories. Game-specific data from community play logs captures these differences accurately.
Filtering by Experience Level
The playtime_experience parameter adjusts playtime filtering:
GET /games?playtime_max=120&playtime_source=community&playtime_experience=first_play
This asks: “Show me games where a first play fits in 2 hours.” Agricola’s community max (130 min) adjusted for first_play (× 1.58 = 205 min) exceeds 120, so it is correctly excluded – a first play of Agricola will not fit in 2 hours.
See Filter Dimensions for the full parameter reference.
Games Without Experience Data
Games with fewer than a minimum number of experience-tagged play logs fall back to global default multipliers (derived from aggregate data across all games). The sufficient_data flag in the experience profile indicates whether game-specific or global multipliers are in use.
OpenTabletop’s Approach
Input Contract
The playtime model follows the specification’s Input Contract principles:
| Element | Playtime-Specific Definition |
|---|---|
| Question | “How long did your session of [Game] take?” |
| Scale | Minutes (integer) |
| Context captured | Player count for this session, experience level (first play / learning / experienced / expert per ADR-0034), whether rules teaching was included |
| Transparency | “Your reported time is bucketed by experience level and player count. The aggregate reflects play times for your experience bracket, not all players.” |
Future: Play Time Distribution
The current model stores the range (min/max). A future version of the specification may include full distribution data:
- Median play time
- Percentile breakdown (25th, 50th, 75th, 90th)
- Play time by player count (2p median, 3p median, etc.)
- Play time trend over time (are plays getting faster as the community learns the game?)
This data exists in play logs and is planned for the statistics foundation. The current range fields are a pragmatic starting point.
Age Recommendation Model
Age recommendations serve two audiences: parents choosing games for children, and groups gauging whether a game’s complexity matches their comfort level. The publisher prints “14+” on the box, but the community may consider 12 perfectly appropriate. The age recommendation model captures both perspectives as separate, independent data points.
Publisher-Stated Age
The min_age field on the Game entity stores what the publisher prints on the box:
| Field | Type | Description |
|---|---|---|
min_age | integer | Publisher’s recommended minimum age in years |
This is a factual record of what the publisher claims. It is useful for filtering and comparison, but it reflects the publisher’s judgment – which is shaped by factors beyond gameplay suitability.
Why Publisher Ages Are Conservative
Publisher age recommendations are systematically biased toward higher numbers for several reasons:
- Liability. A lower age recommendation increases the publisher’s exposure if a parent considers the game inappropriate. Erring high is the safer legal default.
- Complexity conflation. A “14+” rating often means “this is a complex strategy game,” not “this game contains content inappropriate for 13-year-olds.” The age number conflates cognitive difficulty with content suitability.
- Regulatory variation. Different countries have different safety and labeling requirements. The “3+” CE marking in the EU is about small parts and choking hazards, not gameplay difficulty. Publishers sometimes set a higher age floor to sidestep regulatory categories entirely.
- One size fits all. A single number cannot capture “simple enough at 10 with parental guidance, independently at 14.” Publisher ratings must collapse a spectrum into a single threshold.
Community Age Polls
The CommunityAgePoll entity captures community votes on what minimum age they would recommend:
| Field | Type | Description |
|---|---|---|
game_id | UUIDv7 | The game being rated |
suggested_age | integer | The minimum age voters selected |
vote_count | integer | Number of voters who selected this age |
Community members vote on the age they believe is appropriate. Unlike player count polls (which offer Best/Recommended/Not Recommended), age polls are simpler: voters pick the minimum age they would suggest. The distribution of votes reveals how the community’s assessment compares to the publisher’s.
The Game entity includes a pre-computed derived field:
| Field | Type | Description |
|---|---|---|
community_suggested_age | integer (nullable) | Community-polled suggested minimum age |
This is computed as the median of all votes, providing a single representative value. The raw poll data is always available for custom analysis.
Example: Pandemic
Pandemic is a cooperative strategy game. The publisher rates it at 8+.
| Suggested Age | Votes |
|---|---|
| 6 | 12 |
| 8 | 87 |
| 10 | 298 |
| 12 | 134 |
| 14 | 23 |
The community suggested age is 10 – two years higher than the publisher’s box rating. While an 8-year-old can physically play (move pawns, draw cards), the cooperative strategy layer – managing hand cards across players, planning multi-turn cure sequences, and prioritizing outbreak containment – requires the kind of forward planning that most voters consider a 10-year-old task. The gap between “can play” and “can meaningfully contribute to strategy” is exactly what the community poll captures.
Example: Ticket to Ride
Ticket to Ride is a gateway game. The publisher rates it at 8+.
| Suggested Age | Votes |
|---|---|
| 6 | 89 |
| 8 | 423 |
| 10 | 67 |
The community suggested age is 8 – aligning with the publisher. Simpler games tend to show stronger publisher-community agreement because there is less ambiguity between “can physically play” and “can strategically engage.”
Age with Expansions
Expansions can change age recommendations. Pandemic Legacy: Season 1 is publisher-rated at 13+ while the base Pandemic is 8+ – the legacy campaign introduces mature themes (permanent city destruction, character death, narrative tension) and requires a sustained multi-session commitment that raises the appropriate age significantly.
Age changes are modeled through the same property delta system as player count and play time:
- PropertyModification: An expansion can set a new
min_agevalue (e.g., Pandemic Legacy: Season 1 setsmin_ageto 13). - ExpansionCombination: An explicit combination record can include an
effective_min_agewhen the combined age recommendation has been community-verified.
Multi-expansion combination ages are not assumed from individual expansion data. The system only includes effective_min_age when the value has been explicitly curated – it does not guess from individual expansion ages.
When effective mode is enabled, age filtering considers expansion-modified recommendations where available.
OpenTabletop’s Approach
Input Contract
The age recommendation model follows the specification’s Input Contract principles:
| Element | Age-Specific Definition |
|---|---|
| Question | “What is the youngest age you’d recommend for [Game]?” |
| Scale | Age in years (integer) |
| Context captured | Basis for assessment: played with children of this age / professional judgment (educator, child development) / general impression. Whether the voter has children. |
| Transparency | “Your recommendation is recorded alongside your basis. The aggregate is weighted by basis type – assessments from voters who have played with children of the recommended age carry more weight than general impressions.” |
Data Provenance
Community age polls reflect the voting community’s perspective – predominantly experienced hobbyist gamers who may assess age appropriateness differently from parents, educators, or child development specialists. A hobbyist who taught their 8-year-old Pandemic may vote “8” based on their child’s specific aptitude, while a parent browsing for family games may have a more conservative threshold.
The raw vote distribution enables consumers to apply their own interpretive thresholds appropriate to their audience. See Data Provenance & Bias for more on how community data is shaped by who contributes it.
Identifiers
Every entity in the OpenTabletop data model has two native identifiers and can carry any number of external cross-references. This three-layer system balances machine efficiency, human readability, and interoperability with existing platforms.
UUIDv7
The primary identifier for every entity is a UUIDv7 – a universally unique identifier that embeds a millisecond-precision timestamp.
01967b3c-5a00-7000-8000-000000000001
└─────────┘
timestamp
Why UUIDv7
- Time-sortable. UUIDv7s sort chronologically by creation time. This means database indexes on primary keys are naturally ordered, IDs in paginated results have a meaningful sequence, and you can extract the approximate creation time from any ID without a database lookup.
- Globally unique. No coordination between servers is needed to generate IDs. Any implementation can mint UUIDv7s independently with negligible collision probability.
- No information leakage. Unlike sequential integer IDs, you cannot determine how many entities exist by looking at an ID. An ID of
01967b3c-5a00-7000-8000-000000000001does not tell you whether this is the first game or the hundred-thousandth. - Standard format. UUIDv7 is defined in RFC 9562 and supported by every major language and database.
UUIDv4 was considered but rejected because it is not time-sortable, which degrades B-tree index performance and makes keyset pagination less efficient. Integer auto-increment was considered but rejected because it leaks entity counts, creates coordination requirements in distributed systems, and makes cross-system merging difficult.
Slugs
Every entity also has a slug – a human-readable, URL-safe identifier:
twilight-imperium
war-of-the-ring
reiner-knizia
days-of-wonder
Slug Rules
- Lowercase ASCII letters, digits, and hyphens only
- No leading or trailing hyphens
- No consecutive hyphens
- Maximum 100 characters
- Unique within each entity type (a Game slug and a Person slug may collide, but two Game slugs may not)
- Immutable after creation (if a game is renamed, the slug stays the same; an alias may be added)
Why Both
UUIDv7 is for machines. Slugs are for humans. Both are valid lookup keys:
GET /games/01967b3c-5a00-7000-8000-000000000001
GET /games/twilight-imperium
Both return the same Game entity. The API accepts either form wherever a game identifier is expected. Internally, the slug resolves to the UUIDv7 and all storage and indexing uses the UUID.
Slugs appear in URLs, documentation, examples, and conversation. UUIDv7s appear in machine-to-machine communication, foreign keys, and bulk operations.
External Identifiers
The Identifier entity stores cross-references to external systems:
| Field | Type | Required | Description |
|---|---|---|---|
game_id | UUIDv7 | yes | The OpenTabletop entity this identifier belongs to |
source | string | yes | The external system (see below) |
external_id | string | yes | The ID in the external system |
Known Sources
| Source | Format | Example | Description |
|---|---|---|---|
bgg | integer (as string) | "233078" | BoardGameGeek thing ID |
bgg_family | integer (as string) | "55" | BoardGameGeek family ID |
bga | string | "twilightimperium" | Board Game Arena game slug |
isbn | string | "978-1-63344-363-5" | ISBN for games sold as books |
ean | string | "0841333106652" | EAN/UPC barcode |
asin | string | "B07DYBSNKP" | Amazon Standard Identification Number |
wikidata | string | "Q1748930" | Wikidata entity ID |
The source field is an open vocabulary – new sources can be added without a specification change. The known sources above are documented as conventions, not an exhaustive list.
Lookup by External ID
GET /games?identifier_source=bgg&identifier_value=233078
Returns the OpenTabletop Game entity that maps to BGG thing ID 233078 (Twilight Imperium: Fourth Edition). This is essential for migration: applications moving from BGG’s API can look up their existing BGG IDs to find the corresponding OpenTabletop UUIDs.
Multiple External IDs
A single game can have multiple identifiers from the same source. This handles cases like:
- A game listed separately on BGG for different editions (original and revised)
- Multiple ISBNs for different printings
- Regional EAN/UPC codes
{
"id": "01967b3c-5a00-7000-8000-000000000001",
"slug": "twilight-imperium",
"identifiers": [
{ "source": "bgg", "external_id": "233078" },
{ "source": "bga", "external_id": "twilightimperium" },
{ "source": "ean", "external_id": "0841333106652" },
{ "source": "wikidata", "external_id": "Q1748930" }
]
}
Identifier Stability
OpenTabletop identifiers (UUIDv7 and slug) are permanent. Once assigned, they never change and are never reused. If a game is removed from the database, its IDs are retired – they will return a 410 Gone response, not a 404, and will never be assigned to a different entity.
External identifiers may change if the external system reassigns them, but the OpenTabletop cross-reference is updated to reflect the current state. Historical mappings may be preserved with a deprecated flag in future specification versions.
Data Provenance & Bias
Every number in a board game database is somebody’s opinion. Weight, playtime, ratings, player count recommendations – none of these are intrinsic properties of a game. They are perceptions, shaped by who measured them, how they measured, and what they compared against. The OpenTabletop specification is designed with this reality in mind.
Two Layers of Subjectivity
Board game data has two distinct sources, and both are subjective:
Layer 1: Designer Intent
The values printed on the box – player count range, play time, recommended age – come from the designer and playtesters. These are educated estimates made by a small group of people deeply familiar with the game, often in controlled testing conditions:
- Playtime is estimated during playtesting, typically with experienced players who know the rules. First-time players, groups prone to analysis paralysis, or players unfamiliar with the genre may take 50-200% longer.
- Player count is what the designer validated the rules for. Whether the game is good at those counts is a separate question the designer may not be positioned to answer objectively.
- Age recommendation reflects the designer’s judgment about cognitive complexity, not a rigorous developmental assessment.
These values are useful as a baseline, but they represent a single data point from a specific (and non-representative) group.
Layer 2: Community Perception
Community-sourced data – weight ratings, play time logs, player count polls, game ratings – comes from voters on platforms like BoardGameGeek. This data has several structural biases:
- Self-selection. The people who rate games, log plays, and vote on player counts are not a random sample of “gamers.” They are engaged hobbyists who spend time on board game websites – a population that, on platforms like BGG, skews toward experienced, Western, English-speaking enthusiasts. OpenTabletop’s design as a specification (not a single platform) enables multiple independent communities to maintain their own voting populations. A Japanese implementation reflects Japanese gamer preferences; a German implementation reflects German preferences. The bias is a property of who contributes to a specific instance, not the specification itself.
- Experience asymmetry. Experienced players are overrepresented. Someone who has played Everdell 50 times contributes the same one vote as someone who played it once. But their perception of weight and playtime will be very different.
- Recency and novelty. Games get the most votes shortly after release, when excitement is high. Ratings may drift downward (or upward) as the initial enthusiasm fades and more critical voices weigh in.
- Cultural context. Different gaming cultures value different things. The German-speaking eurogame community, the American thematic/Ameritrash community, the East Asian gaming community, and the wargaming community would each produce different top-100 lists, different weight calibrations, and different player count recommendations.
What This Means for Specific Metrics
Ratings Are Taste
A game rated 8.3 is not objectively “better” than one rated 7.8. Ratings reflect the intersection of game design and voter population preferences. The BGG top 100 is a popularity contest within a specific demographic – experienced hobbyist gamers who self-select into an English-language board game community and spend time rating games.
Different populations would produce entirely different top-100 lists:
- Families with young children would elevate accessible, shorter games.
- Wargamers would surface hex-and-counter simulations that rarely appear on BGG’s overall rankings.
- Party gamers would rank social deduction and word games much higher.
- Non-Western markets would include games with limited distribution in North America and Europe.
None of these lists would be more “correct” than any other. They would each accurately reflect their community’s preferences. Because OpenTabletop is a specification – not a centralized platform – these different communities can each run conforming servers with their own data, producing population-specific rankings that honestly represent their audience rather than pretending to be universal.
Weight Is Perceptual
A game does not “have” a weight of 3.86. A specific population of voters rated it 3.86. This number is a useful signal – it tells you where that community places the game on a complexity spectrum – but it is not an intrinsic property of the game.
Consider Everdell. An experienced strategy gamer who plays heavy euros might rate it 2.0 (“light – straightforward tableau building with limited interaction”). A casual gamer encountering it for the first time might rate it 4.0 (“complex – many card combos, resource types, and seasonal timing to track”). Both are valid assessments from different reference frames.
The specification exposes weight as a vote distribution, not just an average, precisely because the distribution tells a richer story. A bimodal distribution (many 2.0 votes and many 4.0 votes) signals genuine disagreement about complexity – likely because the voter population spans different experience levels.
Player Count Ratings Are Population-Dependent
When the community rates Everdell at 4.8/5 at 2 players and 2.9/5 at 4 players, those ratings reflect the priorities of experienced BGG voters who value low downtime and tight resource competition. A casual group playing for the aesthetics and card combos might find 4-player Everdell perfectly enjoyable – their threshold for “too much downtime” may be different.
The specification stores raw vote distributions so that consumers can apply their own interpretive thresholds. An app targeting hardcore gamers might use stricter thresholds for “recommended”; a family-oriented app might use looser ones.
Playtime Is Contextual
The same game at the same player count can take wildly different amounts of time depending on:
- Player experience. First plays routinely take 1.5-2x longer than experienced plays. The specification’s experience-adjusted playtime model (ADR-0034) addresses this, but even within experience levels there is high variance.
- Analysis paralysis. Some groups deliberate every decision; others play on instinct. A “90 minute game” can take 45 minutes with fast players or 3 hours with deliberative ones.
- Teaching overhead. A play session that includes rules explanation can double the total time. Community play logs rarely distinguish “time spent teaching” from “time spent playing.”
- Group dynamics. Social conversation, side discussions, food breaks – all extend real-world play time in ways that are culturally variable and not captured by any logging system.
Community play time data provides a more detailed picture than publisher estimates, but it still reflects the play patterns of people who log their games – who tend to be more experienced hobbyist gamers playing with other experienced gamers.
Age Recommendations Are Culturally Variable
A publisher’s “14+” rating and a community’s “12” recommendation both reflect specific cultural assumptions about what children can handle. A European gaming family may consider a 10-year-old ready for medium-weight strategy; an American parent may draw the line at 14. Neither is wrong – they apply different thresholds for cognitive challenge, thematic content, and session length tolerance.
The specification stores both publisher-stated and community-assessed ages as independent data points. See Age Recommendation Model.
Why the Specification Exposes Distributions
This is the philosophical core of Pillar 3: Statistical Foundation. By exposing raw vote distributions, percentiles, and per-player-count breakdowns, the specification lets consumers decide how to interpret the data for their own audience.
The specification does not say “this game is heavy.” It says “here is how N people voted on complexity – here is the distribution, the mean, the spread.” An application serving experienced eurogamers can interpret that distribution differently than an application helping families find game-night picks.
| What the spec provides | What it does NOT claim |
|---|---|
| Rating distribution | “This game IS an 8.3/10 game” |
| Vote distribution for weight | “This game IS weight 3.86” |
| Per-count rating breakdowns | “This game IS best at 2” |
| Community playtime percentiles | “This game TAKES 120 minutes” |
| Publisher + community age data | “This game IS appropriate for 12+” |
The raw data is the foundation. The interpretation belongs to the consumer.
Input Contract
Every voter-facing data collection point in the specification – ratings, weight, player count quality, playtime, age recommendations – must follow an input contract that ensures the data is interpretable before any statistical model touches it. If voters don’t understand what they’re being asked, no amount of mathematical sophistication fixes the resulting data.
The Four Principles
-
The question must be unambiguous. The voter must know exactly what they’re rating. “Rate this game” is insufficient – rate what about it? Each model doc specifies the exact question to present.
-
The scale must be defined and visible at input time. Anchor definitions (what does 1 mean? what does 5 mean?) must be shown to the voter before they vote, not buried in documentation. Scale calibration reduces the variance caused by voters interpreting numbers differently.
-
The voter’s context must be captured as metadata. Declared scale preference (if the model supports it), experience level with this specific game, number of plays, and any other relevant context – recorded alongside the vote, not inferred after the fact. Context is the normalization key.
-
What happens with the data must be transparent. If a vote is normalized, weighted, or adjusted, the voter should understand how. “Your 4/5 is recorded as 8/10 on the canonical scale because you declared a 1-5 preference” – not a black box.
Per-Model Contracts
Each community-input model defines its own specific input contract:
| Model | Question | Scale | Key Context |
|---|---|---|---|
| Rating | “Rate your overall experience with [Game]” | 1-10 (voter-declared scale supported) | Declared scale preference, number of plays |
| Weight | “How complex is [Game]?” | 1.0-5.0 with anchor games | Experience level, number of plays |
| Player Count | “Rate your experience playing [Game] at [N] players” | 1-5 per count | Number of plays at this count |
| Play Time | “How long did your session of [Game] take?” | Minutes | Player count, experience level, teaching included? |
| Age Recommendation | “What is the youngest age you’d recommend for [Game]?” | Age in years | Basis (played with children, professional, gut feeling) |
Voter Context and the Player Entity
The context captured per-vote (declared scale, experience level, play count) is a subset of the voter’s persistent profile. When a voter has a Player entity, their declared preferences (rating scale, experience level) are stored once and applied across all their votes – they don’t need to re-declare their scale preference every time they rate a game.
For anonymous votes (no linked Player entity), per-vote context metadata is still captured where possible, but lacks the longitudinal continuity that a Player profile provides. See Players & Collections for how the Player entity connects to each model’s input contract and enables corpus-based analysis.
Implications for Implementations
Conforming implementations should consider data provenance when serving their users:
- Be transparent about sources. If weight data comes from BGG, say so. If it comes from a different community (a Japanese board game site, a wargaming forum), that context matters for interpretation.
- Consider your audience. If your implementation serves casual/family gamers, BGG-sourced weight ratings may not align with your users’ perceptions. Providing your own community’s weight data alongside BGG-sourced data lets users see both perspectives.
- Don’t conflate sample size with accuracy. More votes does not mean “more correct” – it means more precise within that population. 10,000 votes from experienced eurogamers still only tells you what experienced eurogamers think.
- Expose the distribution. The specification’s data structures are designed for distributional data precisely so that downstream consumers can make informed interpretations. Collapsing a distribution to a single number loses the signal that matters most: the shape of disagreement.
Materialization
The OpenTabletop data model separates raw input data from materialized aggregates. This is a deliberate architectural decision, not an implementation detail. Understanding the two tiers explains why certain fields on the Game entity are not computed in real time, when they refresh, and how they relate to the snapshot infrastructure described in ADR-0036.
Two Data Tiers
Tier 1: Raw Input Data
Raw input data is the individual, immutable records submitted by users and communities:
- Rating votes – Individual 1-10 ratings with voter-declared scale preference (see Rating Model)
- Weight votes – Individual 1.0-5.0 complexity votes (see Weight Model)
- Play logs – Individual session records with duration, player count, and experience level
- Player count poll responses – Per-count best/recommended/not-recommended votes (see Player Count Model)
- Age poll votes – Individual suggested-age votes
- Collection records – Per-user ownership, wishlist, and play status entries
These records are append-only. A new rating vote is inserted; it does not update a running total in place. This preserves the full distribution for statistical analysis and makes the raw data exportable (Pillar 3).
Tier 2: Materialized Aggregates
Materialized aggregates are the derived fields that appear on the Game entity and related responses. They are computed periodically from Tier 1 data:
- Averages, counts, and distributions (e.g.,
average_rating,rating_distribution) - Confidence scores that depend on global statistics
- Rankings that are relative to the entire dataset
- Bayesian priors that use global parameters
- Derived fields like
top_player_countsand experience multipliers
These fields are read-optimized snapshots of computations over the raw data. They are not updated on every write to Tier 1.
Why Not Real-Time
Computing aggregates on every incoming vote is wasteful and, for several fields, architecturally impossible without full table scans:
rating_distributionacross 50,000 votes requires reading every vote to build the histogram. Recomputing this on every new vote is O(n) per write instead of O(1).rating_confidencedepends on the global mean across all games – a single new vote on one game could theoretically shift the confidence score of every other game. Recomputing globally on every write is infeasible.rank_overallis a relative ordering of all games bybayes_rating. A single new vote that changes one game’s Bayesian average requires re-sorting and re-ranking the entire dataset.bayes_ratinguses a Dirichlet prior whose parameters are derived from the global rating distribution. The prior itself is a materialized value.- Experience multipliers require grouping play logs by experience level and computing per-level medians – an aggregation over the full play log table.
Batch materialization converts these from O(n) per write to O(n) per scheduled run, amortized across all writes in the interval. For a dataset with thousands of votes per day, this is the difference between seconds of compute per vote and seconds of compute per day.
Materialization Schedule
The recommended default is daily materialization. This balances freshness against compute cost:
| Cadence | Fields | Rationale |
|---|---|---|
| Daily (recommended default) | average_rating, rating_count, rating_distribution, rating_stddev, rating_confidence, bayes_rating, weight, weight_votes, community_playtime_*, community_suggested_age, owner_count, wishlist_count, total_plays, top_player_counts, recommended_player_counts, experience multipliers | Most fields benefit from daily refresh. Vote volumes for any single game are low enough that daily captures the signal without waste. |
| Weekly (or less frequent) | rank_overall | Rankings are expensive to compute (full sort of all games) and consumers do not expect them to shift hour by hour. Weekly is sufficient for most use cases. |
The updated_at field on the Game entity indicates when aggregates were last refreshed. API consumers can check this timestamp to assess staleness. An updated_at of yesterday means the aggregates reflect all votes through yesterday’s materialization run; votes submitted today are in Tier 1 but not yet reflected in Tier 2.
Field-to-Source Mapping
Every materialized field on the Game entity traces back to a specific raw data source and a defined computation:
| Materialized Field | Raw Source | Computation |
|---|---|---|
average_rating | rating_votes | Mean of all votes (normalized to 1-10) |
rating_count | rating_votes | COUNT |
rating_distribution | rating_votes | Histogram: count per 1-10 bucket |
rating_stddev | rating_votes | Standard deviation |
rating_confidence | rating_votes + global stats | f(count, distribution shape, deviation from global mean) |
bayes_rating | rating_votes + global prior | Dirichlet-prior Bayesian average |
weight | weight_votes | Mean |
weight_votes (count) | weight_votes | COUNT |
community_playtime_* | play_logs | 10th/50th/90th percentile |
community_suggested_age | age_poll_votes | Median |
owner_count | collections | COUNT WHERE status = ‘owned’ |
wishlist_count | collections | COUNT WHERE status = ‘wishlist’ |
total_plays | play_logs | SUM |
rank_overall | All bayes_rating values | Sort + assign ordinal |
top_player_counts | player_count_ratings | Counts where avg >= threshold |
recommended_player_counts | player_count_ratings | Counts where avg >= lower threshold |
| Experience multipliers | play_logs grouped by level | Per-level median / experienced median |
For the detailed specification of each field, see: Rating Model, Weight Model, Player Count Model, Play Time Model.
Data Flow
The materialization pipeline is a scheduled batch process that reads Tier 1, computes Tier 2, and writes the results back to the Game entity:
flowchart LR
subgraph "Tier 1: Raw Input Data"
RV["Rating Votes"]
WV["Weight Votes"]
PL["Play Logs"]
PC["Player Count Polls"]
AP["Age Poll Votes"]
CL["Collections"]
end
MJ["Materialization Job<br/><i>(daily cron)</i>"]
subgraph "Tier 2: Materialized Output"
GA["Game Entity<br/>Aggregates"]
GS["GameSnapshot<br/><i>(ADR-0036)</i>"]
end
RV --> MJ
WV --> MJ
PL --> MJ
PC --> MJ
AP --> MJ
CL --> MJ
MJ --> GA
MJ --> GS
style MJ fill:#f57c00,color:#fff
style GA fill:#388e3c,color:#fff
style GS fill:#1976d2,color:#fff
GameSnapshot as Materialization Artifact
The GameSnapshot schema exists to enable longitudinal trend analysis – tracking how a game’s rating, rank, and activity metrics change over time. The materialization job is the natural place to capture these snapshots.
When the daily materialization runs:
- Raw votes are aggregated into the Game entity’s materialized fields (Tier 2).
- The freshly-computed aggregates are copied into a new
GameSnapshotrecord timestamped to the current run.
The snapshot is the materialized state at that point in time. There is no separate “snapshot job” – the snapshot is a side effect of materialization. This guarantees that the snapshot’s average_rating, bayes_rating, rank_overall, and other fields are internally consistent (they were all computed from the same Tier 1 state in the same run).
This also means snapshot frequency is tied to materialization frequency. If you materialize daily, you get daily snapshots. If you materialize weekly, you get weekly snapshots. The Trend Analysis documentation covers how these snapshots power longitudinal queries.
For implementations that want less frequent snapshots (e.g., monthly) but daily materialization, the job can conditionally emit a snapshot only on the first run of each month. The materialization itself still runs daily to keep Game entity aggregates fresh.
Pillar 2: Filtering & Windowing
Filtering is the showcase feature of OpenTabletop. The data model defines what the data looks like; filtering defines how you ask questions of it. And the questions people actually want to ask – the ones they cannot answer today – are multi-dimensional.
The Problem
It is game night. You have 4 people, about 90 minutes, and your group prefers medium-weight cooperative games. One person does not like space themes. You want suggestions.
Today, there is no way to answer this question with a single query to any existing board game service:
- BGG’s XML API2 supports collection-status filters (owned, want to trade, wishlist, rated, played) and personal rating filters (min/max rating, min/max BGG rating, min/max plays) on the Collection endpoint. The Search endpoint only filters by name and type. But there are no game-property filters – you cannot query by player count, play time, weight, mechanics, or theme via the API. To find “cooperative games for 4 players under 90 minutes,” you must fetch all games and filter client-side.
- BGG’s advanced search (on the website, not the API) supports some game-property filters, but they cannot be combined effectively. You cannot filter by “best at 4” vs “supports 4.” You cannot exclude themes. You cannot use community play times.
- Board Game Atlas had limited filtering before it shut down. It supported player count and play time but not weight, not mechanics combinations, and not expansion-aware effective mode.
The result is that every board game recommendation app, collection manager, and “what should we play” tool either builds its own filtering on top of scraped data or punts the problem to the user with a spreadsheet.
What the OpenTabletop Specification Enables
OpenTabletop defines nine filter dimensions that compose using boolean logic, ordered to match the Pillar 1 data model:
flowchart LR
subgraph Input
Q["Search Query"]
end
subgraph Dimensions
D1["Rating & Confidence"]
D2["Weight"]
D3["Player Count"]
D4["Play Time"]
D5["Age"]
D6["Game Type & Mechanics"]
D7["Theme"]
D8["Metadata"]
D9["Corpus (aspirational)"]
end
subgraph Logic
AND["Cross-dimension: AND"]
OR["Within dimension: OR"]
end
subgraph Output
R["Filtered Results"]
end
Q --> D1
Q --> D2
Q --> D3
Q --> D4
Q --> D5
Q --> D6
Q --> D7
Q --> D8
Q --> D9
D1 --> AND
D2 --> AND
D3 --> AND
D4 --> AND
D5 --> AND
D6 --> AND
D7 --> AND
D8 --> AND
D9 --> AND
AND --> R
Each dimension can contain multiple criteria (combined with OR logic within the dimension), and all active dimensions are combined with AND logic across dimensions. See Dimension Composition for the full boolean model.
The Key Differentiators
-
Rating confidence. Filter not just by rating value but by rating reliability. A confidence score (0-1) captures whether a rating is trustworthy or the product of brigading, tiny sample size, or extreme polarization. See Rating Model.
-
Dimensional weight. Filter by composite weight or by individual dimensions – “high strategic depth but low fiddliness” separates good complexity from tedious bookkeeping. See Weight Model.
-
Community-aware player count. Filter by “supports 4” OR “highly rated at 4” OR “acceptable at 4.” These are three different questions with three different answer sets, using numeric per-count ratings rather than categorical buckets. See Player Count Model.
-
Dual play time sources. Filter by publisher-stated time or community-reported time, with experience-level adjustment. “Show me games where a first play fits in 2 hours” is a real query. See Play Time Model.
-
Expansion-aware effective mode. The
effective=trueflag searches across expansion combinations. A game that only supports 4 players in its base form but supports 6 with an expansion will appear in aplayers=6&effective=truesearch. No other API can do this. See Effective Mode. -
Age recommendation sources. Filter by publisher-stated age or community-suggested age. Publisher ages tend to be conservative; community data provides an alternative view. See Age Recommendation Model.
-
Theme exclusion. Not just “include cooperative games” but “exclude space-themed games.” Negative filters are first-class. See Filter Dimensions.
-
Mechanic composition. Find games that have ALL of a set of mechanics (
mechanics_all=["cooperative", "hand-management"]) or ANY of a set (mechanics=["cooperative", "hand-management"]). See Filter Dimensions. -
Single endpoint, JSON body. Complex queries use
POST /games/searchwith a JSON body instead of URL parameter soup. See Search Endpoint. -
Corpus-based filtering (aspirational). “What do players like me think?” – filter by player archetype or collection similarity, enabled by the Player entity.
The Game Night Query
The scenario from the introduction, as an actual API call:
POST /games/search HTTP/1.1
Content-Type: application/json
{
"players": 4,
"playtime_max": 90,
"playtime_source": "community",
"weight_min": 2.0,
"weight_max": 3.5,
"mechanics": ["cooperative"],
"theme_not": ["space"],
"sort": "rating_desc",
"limit": 20
}
This query asks: “Find games that support exactly 4 players, with community-reported play time under 90 minutes, at medium weight (2.0-3.5), using cooperative mechanics, excluding space-themed games, sorted by rating, top 20 results.”
Every parameter maps to a specific filter dimension. Every dimension is documented with its full parameter reference in Filter Dimensions. The way they combine is documented in Dimension Composition. Real-world scenarios with full request/response examples are in Real-World Examples.
Filter Dimensions
The filtering system is organized into nine dimensions, ordered to match the Pillar 1 data model sequence. Each dimension addresses a distinct aspect of the query, and they combine using the composition rules described in Dimension Composition.
Dimension 1: Rating & Confidence
Filter games by community rating quality and reliability.
| Parameter | Type | Description |
|---|---|---|
rating_min | float | Minimum community rating (1.0 - 10.0). |
rating_max | float | Maximum community rating (1.0 - 10.0). |
min_rating_votes | integer | Minimum number of rating votes (excludes obscure games). |
confidence_min | float | Minimum rating confidence score (0.0 - 1.0). See Rating Model. |
Notes:
rating_min=7.0&min_rating_votes=1000finds well-rated games with enough votes to be statistically meaningful.confidence_min=0.7filters out games with unreliable ratings (brigaded, too few votes, or highly polarized). See the pre-release brigading case study for why this matters.- Confidence captures what vote count alone cannot: a game with 500 votes and std dev 4.0 has low confidence despite a reasonable sample size.
Dimension 2: Weight (Complexity)
Filter games by their community-voted complexity score.
| Parameter | Type | Description |
|---|---|---|
weight_min | float | Minimum composite weight (1.0 - 5.0 scale). |
weight_max | float | Maximum composite weight (1.0 - 5.0 scale). |
effective_weight | boolean | When true and effective=true, uses expansion-modified weight values. |
min_weight_votes | integer | Minimum number of weight votes (excludes games with unreliable weight data). |
Notes:
weight_min=2.0&weight_max=3.5selects “medium weight” games – substantial decisions without being overwhelming.- Weight is a community-voted value on a 1.0-5.0 scale. See Weight Model for the scale interpretation and known limitations.
- Games with fewer than a configurable threshold of weight votes (default: 30) can be excluded with
min_weight_votes=30.
Dimensional Weight Filters (Implementation-Dependent)
Implementations that support the detailed weight mode may expose per-dimension filters:
| Parameter | Type | Description |
|---|---|---|
weight_rules_complexity_min | float | Minimum rules complexity (1-5). |
weight_rules_complexity_max | float | Maximum rules complexity (1-5). |
weight_strategic_depth_min | float | Minimum strategic depth (1-5). |
weight_strategic_depth_max | float | Maximum strategic depth (1-5). |
weight_decision_density_min | float | Minimum decision density (1-5). |
weight_decision_density_max | float | Maximum decision density (1-5). |
weight_cognitive_load_min | float | Minimum cognitive load (1-5). |
weight_cognitive_load_max | float | Maximum cognitive load (1-5). |
weight_fiddliness_min | float | Minimum fiddliness (1-5). |
weight_fiddliness_max | float | Maximum fiddliness (1-5). |
These enable queries like “high strategic depth but low fiddliness” – separating good complexity from tedious bookkeeping. These parameters are optional – only available if the implementation collects dimensional weight data.
Dimension 3: Player Count
Filter games by how many people can play them.
| Parameter | Type | Description |
|---|---|---|
players | integer | Exact player count. Matches games where min_players <= players <= max_players. |
players_min | integer | Minimum player count. The game’s min_players must be >= this value. |
players_max | integer | Maximum player count. The game’s max_players must be <= this value. |
top_at | integer | Community highly-rated player count. Matches games where the per-count average rating exceeds a high threshold (e.g., 4.0+/5) for this count. See Player Count Model. |
recommended_at | integer | Community acceptable player count. Matches games where the per-count average rating exceeds a moderate threshold (e.g., 3.0+/5) for this count. |
effective | boolean | When true, also searches across expansion combinations. See Effective Mode. |
include_integrations | boolean | When true (and effective=true), also searches combinations involving integrates_with products. Default: false. See Effective Mode: Integration modifier. |
Semantics:
| Parameter | Condition | Meaning |
|---|---|---|
players | min_players <= players <= max_players | The game supports this exact count |
players_min | min_players >= players_min | The game requires at least this many players to start |
players_max | max_players <= players_max | The game caps at this many players |
top_at | Per-count average rating >= high threshold | Community rates this count highly |
recommended_at | Per-count average rating >= moderate threshold | Community considers this count acceptable |
Notes:
players=4means “supports exactly 4 players” – the game’s range includes 4.top_at=4means “the community rates the 4-player experience highly.” This is a much smaller set thanplayers=4. The threshold is implementation-defined (e.g., 4.0+/5).recommended_at=4is broader thantop_at=4– it includes games where 4 is acceptable but not necessarily the sweet spot. The threshold is implementation-defined (e.g., 3.0+/5).players_min=3returns games that require 3+ players (excludes solo and 2-player games).players_max=4returns games that cap at 4 or fewer (excludes 5+ player games).- Per-count ratings use the numeric model from ADR-0043. Legacy BGG three-tier data (Best/Recommended/Not Recommended) is converted to numeric ratings for filtering. See Player Count Model.
Dimension 4: Play Time
Filter games by how long they take to play.
| Parameter | Type | Description |
|---|---|---|
playtime_min | integer | Minimum play time in minutes. Games must take at least this long. |
playtime_max | integer | Maximum play time in minutes. Games must finish within this time. |
community_playtime_min | integer | Like playtime_min but uses community-reported times. |
community_playtime_max | integer | Like playtime_max but uses community-reported times. |
playtime_source | string | Which time source to use when playtime_min/playtime_max are specified: "publisher" (default) or "community". |
playtime_experience | string | Experience level adjustment: "first_play", "learning", "experienced", or "expert". Adjusts game playtime values by the experience multiplier before comparison. See Play Time Model and ADR-0034. |
Notes:
playtime_max=90withplaytime_source=publishermatches games where the publisher’smax_playtime <= 90.playtime_max=90withplaytime_source=communitymatches games wherecommunity_max_playtime <= 90.- You can use
community_playtime_maxdirectly instead of theplaytime_sourcetoggle for explicit control. - When
effective=true, play time considers expansion combinations. See Effective Mode. playtime_experience=first_playadjusts game times upward before comparison. A game with community max 90 min and a 1.5x first-play multiplier is treated as 135 min for filtering. This composes withplaytime_sourceandeffective=true: source is selected first, then expansion resolution, then experience adjustment, then comparison.
Dimension 5: Age Recommendation
Filter games by age appropriateness.
| Parameter | Type | Description |
|---|---|---|
age_min | integer | Minimum age. Uses whichever source is selected by age_source. |
age_max | integer | Maximum age. Uses whichever source is selected by age_source. |
community_age_min | integer | Like age_min but explicitly uses community-suggested age. |
community_age_max | integer | Like age_max but explicitly uses community-suggested age. |
age_source | string | Which age source to use when age_min/age_max are specified: "publisher" (default, uses min_age) or "community" (uses community_suggested_age). |
Notes:
age_max=10withage_source=publishermatches games where the publisher’smin_age <= 10.age_max=10withage_source=communitymatches games wherecommunity_suggested_age <= 10.- You can use
community_age_maxdirectly instead of theage_sourcetoggle for explicit control. - Publisher age recommendations tend to be conservative (see Age Recommendation Model). Community age data provides an alternative perspective based on actual play experience.
Dimension 6: Game Type & Mechanics
Filter by the game’s type discriminator and its mechanical classification.
| Parameter | Type | Description |
|---|---|---|
type | string or string[] | Game type(s): base_game, expansion, standalone_expansion, promo, accessory, fan_expansion. Default: ["base_game", "standalone_expansion"]. |
mode | string | Shorthand for common type combinations: "all", "playable" (base + standalone), "addons" (expansion + promo + accessory). |
mechanics | string[] | Games must have ANY of these mechanics (OR logic). |
mechanics_all | string[] | Games must have ALL of these mechanics (AND logic). |
mechanics_not | string[] | Games must have NONE of these mechanics (exclusion). |
Notes:
typedefaults to["base_game", "standalone_expansion"]because most queries want playable games, not individual expansions or promos.mechanics=["cooperative", "deck-building"]matches games with cooperative OR deck-building (or both).mechanics_all=["cooperative", "hand-management"]matches games with BOTH cooperative AND hand-management.mechanics_not=["player-elimination"]excludes games with player elimination.- These three mechanic parameters can be combined:
mechanics_all=["cooperative"]&mechanics_not=["dice-rolling"]finds cooperative games that do not use dice.
Dimension 7: Theme
Filter by thematic setting.
| Parameter | Type | Description |
|---|---|---|
theme | string[] | Games must have ANY of these themes (OR logic). |
theme_not | string[] | Games must have NONE of these themes (exclusion). |
Notes:
theme=["fantasy", "mythology"]matches games themed around fantasy OR mythology.theme_not=["space"]excludes all space-themed games.- Theme inclusion and exclusion can be combined:
theme=["historical"]&theme_not=["war"]finds historical games that are not war-themed.
Dimension 8: Metadata
Filter by publication metadata and creators.
| Parameter | Type | Description |
|---|---|---|
designer | string[] | Games designed by ANY of these people (by slug or ID). |
publisher | string[] | Games published by ANY of these organizations (by slug or ID). |
family | string[] | Games in ANY of these families (by slug or ID). |
category | string[] | Games in ANY of these categories (by slug). |
year_min | integer | Published in or after this year. |
year_max | integer | Published in or before this year. |
language_dependence | string or string[] | Filter by text dependence level: "no_text", "some_text", "moderate_text", "extensive_text", "unplayable_without_text". Useful for finding games suitable for non-native-language groups. |
Notes:
designer=["cole-wehrle"]finds all games by Cole Wehrle.year_min=2020&year_max=2025finds games published in the 2020s.family=["pandemic"]finds all games in the Pandemic family, regardless of type.language_dependence=["no_text", "some_text"]finds games playable without significant language knowledge – useful for international groups or non-native speakers.
Dimension 9: Corpus & Archetype (Aspirational)
Filter by player population or behavioral archetype. This dimension is enabled by the Player entity and represents a fundamentally new kind of filtering: “what do players like me think?” rather than “what does the undifferentiated crowd think?”
| Parameter | Type | Description |
|---|---|---|
corpus | string | Filter by player corpus: similar_to_player:{id} or archetype:{name}. |
corpus_rating_min | float | Minimum rating within the specified corpus. |
Example queries:
corpus=similar_to_player:01912f4c-a1b2&corpus_rating_min=4.0– “games rated 4.0+ by players whose collection resembles mine”corpus=archetype:solo-gamer&top_at=1– “games solo gamers rate highly at 1 player”
Status: This dimension is aspirational. The Player entity and archetype model define the data structures that make it possible, but the query syntax and implementation details are future RFC topics. Implementations may experiment with corpus-based filtering before the syntax is formally specified.
Sorting
Results can be sorted by:
| Sort Value | Description |
|---|---|
rating_desc | Highest rated first |
rating_asc | Lowest rated first |
confidence_desc | Most reliable ratings first |
weight_desc | Heaviest first |
weight_asc | Lightest first |
year_desc | Newest first |
year_asc | Oldest first |
name_asc | Alphabetical A-Z |
name_desc | Alphabetical Z-A |
playtime_asc | Shortest play time first |
playtime_desc | Longest play time first |
Default sort is rating_desc.
Pagination
All filtered results are paginated using keyset cursors. See Pagination.
| Parameter | Type | Description |
|---|---|---|
limit | integer | Maximum results per page (default: 20, max: 100). |
cursor | string | Opaque cursor from a previous response for the next page. |
Dimension Composition
Filter dimensions compose using a simple, predictable boolean model. The rules are:
- Cross-dimension: AND. All active dimensions must be satisfied. A game must match the player count filter AND the play time filter AND the weight filter AND every other active dimension.
- Within dimension: OR. Multiple values within a single dimension are combined with OR logic.
theme=["fantasy", "mythology"]matches games with fantasy OR mythology. - Exclusion: NOT. Parameters ending in
_notexclude matches.theme_not=["space"]removes all space-themed games from the result set.
The Filter Pipeline
flowchart TD
ALL["All Games in Database"]
subgraph "Dimension Filters (AND across dimensions)"
RC["Rating & Confidence<br/><i>rating_min, confidence_min</i>"]
W["Weight<br/><i>weight_min, weight_max</i>"]
PC["Player Count<br/><i>players, top_at, recommended_at</i>"]
PT["Play Time<br/><i>playtime_min, playtime_max</i>"]
AG["Age<br/><i>age_min, age_max</i>"]
TM["Game Type & Mechanics<br/><i>type, mechanics, mechanics_not</i>"]
TH["Theme<br/><i>theme, theme_not</i>"]
MD["Metadata<br/><i>designer, publisher, year, etc.</i>"]
end
SORT["Sort"]
PAGE["Paginate"]
OUT["Response"]
ALL --> RC
RC --> W
W --> PC
PC --> PT
PT --> AG
AG --> TM
TM --> TH
TH --> MD
MD --> SORT
SORT --> PAGE
PAGE --> OUT
The pipeline is conceptual – the actual database query optimizes the execution order. But the logical behavior is as if each dimension filters the result set in sequence, with every game needing to pass all active filters.
Within-Dimension OR
When a dimension accepts an array of values, those values are combined with OR:
{
"mechanics": ["cooperative", "solo"],
"theme": ["fantasy", "nature"]
}
This matches games that have (cooperative OR solo mechanics) AND (fantasy OR nature theme). It does NOT require a game to have both cooperative and solo mechanics – any one match within the dimension is sufficient.
Within-Dimension AND
For mechanics specifically, the mechanics_all parameter switches to AND logic:
{
"mechanics_all": ["cooperative", "hand-management", "area-control"]
}
This matches only games that have ALL THREE mechanics. This is a much narrower filter.
Combining OR and AND
mechanics (OR), mechanics_all (AND), and mechanics_not (NOT) can all be used together:
{
"mechanics": ["deck-building", "bag-building"],
"mechanics_all": ["cooperative"],
"mechanics_not": ["dice-rolling"]
}
This reads as: “Games that have (deck-building OR bag-building) AND have cooperative AND do NOT have dice-rolling.”
In predicate logic:
(deck-building OR bag-building) AND cooperative AND NOT dice-rolling
Cross-Dimension AND
Every active dimension must be satisfied:
{
"players": 4,
"playtime_max": 90,
"weight_min": 2.0,
"weight_max": 3.5,
"mechanics": ["cooperative"],
"theme_not": ["space"]
}
A game appears in the results only if ALL of these are true:
- It supports 4 players (player count dimension)
- Its play time is at most 90 minutes (play time dimension)
- Its weight is between 2.0 and 3.5 (weight dimension)
- It has the cooperative mechanic (mechanics dimension)
- It does NOT have the space theme (theme dimension)
If any single dimension fails, the game is excluded.
Inactive Dimensions
A dimension that has no parameters set is inactive and matches everything. If you only specify players=4, then rating, weight, play time, age, mechanics, theme, and metadata are all unconstrained – every game that supports 4 players is returned regardless of those other properties.
Effective Mode Interaction
When effective=true, the player count, play time, and weight dimensions expand their search to include expansion combinations. This does not change the composition rules – it changes the data that each dimension searches against. Instead of checking only the base game’s properties, the filter also checks all known ExpansionCombination entries for that game.
See Effective Mode for the full explanation.
Edge Cases
Empty array parameters. An empty array ("mechanics": []) is treated as an inactive filter, not as “no mechanics.” If you want games with literally no mechanics tagged, use a dedicated parameter (not yet specified; this is a future consideration).
Conflicting constraints. weight_min=4.0&weight_max=2.0 is a valid query that returns zero results. The API does not reject logically impossible combinations – it returns an empty result set.
Null fields. Games missing a field (e.g., no community play time data) are excluded from filters on that field unless a fallback is specified. A game with no community_max_playtime will not appear in community_playtime_max=90 results, even if its publisher play time is under 90 minutes. The two data sources are independent.
Effective Mode
Effective mode is the feature that distinguishes OpenTabletop from every other board game API. When effective=true, the filtering system does not just search base game properties – it searches across all known expansion combinations.
The Problem Effective Mode Solves
Spirit Island supports 1-4 players in its base form. If you search for games that support 6 players, Spirit Island does not appear. But if you own Spirit Island and the Jagged Earth expansion, you can play with 6 people. The data exists – Jagged Earth adds support for 5-6 players – but no API exposes it as a searchable property.
Effective mode bridges this gap. With effective=true, the query:
{
"players": 6,
"effective": true
}
will return Spirit Island because the system knows that the Spirit Island + Jagged Earth combination supports 6 players.
How It Works
The effective properties resolution pipeline has four stages, applied in order:
flowchart TD
Q["Query: players=6, effective=true"]
subgraph "Resolution Pipeline"
E["(1) Select edition"]
ED["(2) Apply edition deltas"]
EX["(3) Apply expansion resolution"]
XP["(4) Apply experience adjustment"]
end
subgraph "Filter evaluation"
BG["Check resolved properties: max_players >= 6?"]
end
Q --> E
E --> ED
ED --> EX
EX --> XP
XP --> BG
BG -->|Yes| INCLUDE["Include in results"]
BG -->|No| EXCLUDE["Exclude from results"]
style INCLUDE fill:#388e3c,color:#fff
style EXCLUDE fill:#d32f2f,color:#fff
For each game in the database, effective mode:
- Selects the edition. If the query specifies an
editionparameter, that edition’s deltas are used. Otherwise the canonical edition (marked withis_canonical=true) is used. If no edition data exists, this step is a no-op. - Applies edition deltas to produce the edition-adjusted base properties. Edition deltas are relative to the canonical edition and modify player count, play time, weight, etc. See ADR-0035.
- Applies expansion resolution using the edition-adjusted base as the starting point. This follows the three-tier resolution: explicit
ExpansionCombinationrecords, summed individual deltas, or base fallback. - Applies experience adjustment (ADR-0034) if the query includes an
experienceparameter, scaling playtime values by experience-level multipliers. - Compares the resolved properties against filter values. If any combination of edition + expansions satisfies the filter, the game is included.
What Gets Searched
Effective mode applies to four filter dimensions:
| Dimension | Base fields | Effective fields |
|---|---|---|
| Weight | weight | ExpansionCombination weight field |
| Player Count | min_players, max_players, top_at, recommended_at | ExpansionCombination player fields |
| Play Time | min_playtime, max_playtime, community_min_playtime, community_max_playtime | ExpansionCombination time fields |
| Age | min_age | ExpansionCombination min_age field |
Other dimensions (rating, mechanics, theme, metadata) are not affected by effective mode – they operate on the base game’s properties regardless.
Integration modifier
| Parameter | Type | Default | Description |
|---|---|---|---|
include_integrations | boolean | false | When true (and effective=true), also searches combinations that involve integrates_with products. |
By default, effective mode only considers expansions linked via expands relationships. Products linked via integrates_with – such as standalone introductory versions whose components are physically compatible with the full game – are excluded unless the consumer opts in with include_integrations=true. See the Spirit Island example for why this distinction matters.
Spirit Island Example
Suppose the database contains these expansion combinations for Spirit Island:
| Combination | Players | Best At | Weight | Play Time |
|---|---|---|---|---|
| Base only | 1-4 | 2 | 4.08 | 90-120 |
| + Branch & Claw | 1-4 | 2 | 4.24 | 90-150 |
| + Jagged Earth | 1-6 | 2-3 | 4.52 | 90-120 |
| + Nature Incarnate | 1-6 | 2-3 | 4.46 | 90-180 |
| + B&C + JE | 1-6 | 2-4 | 4.56 | 90-150 |
| + B&C + NI | 1-6 | 2-3 | 4.52 | 90-180 |
| + JE + NI | 1-6 | 2-4 | 4.60 | 90-180 |
| + B&C + JE + NI | 1-6 | 2-4 | 4.65 | 120-180 |
| + Feather & Flame | 1-4 | 2 | 4.55 | 90-120 |
| + Horizons (integration) | 1-4 | 2 | 3.82 | 60-120 |
| + Horizons + B&C (integration) | 1-4 | 2 | 3.98 | 60-150 |
| + Horizons + JE (integration) | 1-6 | 2-3 | 4.10 | 60-120 |
Rows marked (integration) only appear when include_integrations=true.
Spirit Island also has Horizons of Spirit Island, a standalone introductory version (1-3 players, weight 3.56, 60-90 min). As a standalone_expansion, Horizons has its own base properties and appears independently in non-effective searches. Its components – including five introductory spirits – are compatible with the full game via the integrates_with relationship, so Horizons spirits can be shuffled into a base Spirit Island game. These cross-product combinations are valid but opt-in: they only appear in effective mode results when include_integrations=true.
The rationale for making integrations opt-in: expands products are designed as add-ons to a specific base game, so their combinations are predictable and expected. integrates_with products are separate games whose components happen to be compatible – combining them may fundamentally change the experience in ways a casual searcher would not expect. A consumer who owns both products and wants to see all possibilities can opt in; a consumer browsing for games to buy sees only the standard expansion landscape.
Feather & Flame is a compilation that bundles both of Spirit Island’s original Promo Packs (Promo Pack 1 and Promo Pack 2) into a single product. It adds new spirits – including Finder of Paths Unseen, one of the most challenging spirits in the game – along with adversaries, fear cards, scenarios, and aspect cards. While Feather & Flame does not change player count or play time, the added complexity is reflected in its weight (4.55 vs the base game’s 4.08). This means Feather & Flame can be the reason Spirit Island matches a weight filter it would not otherwise match.
Now consider these queries:
Query: players=6 (effective=false)
Spirit Island is excluded. Base game max is 4.
Query: players=6&effective=true
Spirit Island is included. The “Jagged Earth”, “Nature Incarnate”, and all multi-expansion combinations that include either support 6 players.
Query: top_at=4&effective=true
Spirit Island is included. The “B&C + JE”, “JE + NI”, and “B&C + JE + NI” combinations have 4 in their top_at lists.
Query: weight_min=4.4&weight_max=4.6&effective=true
Spirit Island is included. Multiple combinations fall in this range: “Nature Incarnate” (4.46), “Jagged Earth” (4.52), “B&C + NI” (4.52), “Feather & Flame” (4.55), “B&C + JE” (4.56), “JE + NI” (4.60). The system returns the first matching combination.
Query: weight_min=4.55&weight_max=4.65&effective=true
Spirit Island is included via “Feather & Flame” (4.55), “B&C + JE” (4.56), “JE + NI” (4.60), and “B&C + JE + NI” (4.65). Without effective mode, Spirit Island (weight 4.08) would be excluded.
Query: playtime_max=120&effective=true
Spirit Island is included via the base game (max 120), the Jagged Earth combination (max 120), and the Feather & Flame combination (max 120). The Nature Incarnate combinations with max 180 would not be the matching path. The system finds the combination that satisfies the constraint.
Query: weight_max=4.0&effective=true
Spirit Island is excluded. No standard expansion combination has weight <= 4.0 (the base game is 4.08). The Horizons integration combinations (3.82, 3.98) would match, but they are not searched because include_integrations defaults to false.
Query: weight_max=4.0&effective=true&include_integrations=true
Spirit Island is included via the “+ Horizons” integration combination (weight 3.82). This represents a game of Spirit Island using Horizons spirits, which lowers the overall complexity. The matched_via response includes "integration": true so the consumer knows the match involves a cross-product combination.
Response Format
When effective mode produces a match through an expansion combination (not the base game), the response includes metadata about which combination matched:
{
"id": "01967b3c-5a00-7000-8000-000000000001",
"slug": "spirit-island",
"name": "Spirit Island",
"matched_via": {
"type": "expansion_combination",
"combination_id": "01967b3c-6000-7000-8000-000000000055",
"expansions": [
{ "slug": "spirit-island-branch-and-claw", "name": "Branch & Claw" },
{ "slug": "spirit-island-jagged-earth", "name": "Jagged Earth" },
{ "slug": "spirit-island-nature-incarnate", "name": "Nature Incarnate" }
],
"effective_properties": {
"min_players": 1,
"max_players": 6,
"top_at": [2, 3, 4],
"weight": 4.65,
"min_playtime": 120,
"max_playtime": 180,
"min_age": 14
},
"resolution_tier": 1
}
}
The matched_via object tells the consumer exactly how the game satisfied the filter:
type:"base"if the base game matched,"expansion_combination"if a combination matched,"delta_sum"if individual deltas were summed.expansions: Which expansions are in the matching combination.effective_properties: The actual property values used for matching.resolution_tier: 1 (explicit combination), 2 (delta sum), or 3 (base fallback).
Performance Considerations
Effective mode is more expensive than standard filtering because the database must check multiple rows per game (one for each known expansion combination). The specification does not mandate a specific implementation strategy, but reference implementation notes:
- Expansion combinations are pre-computed and indexed. The query adds a JOIN, not a recursive computation.
- Games without any expansion data are checked only against their base properties (no overhead).
- The
typefilter defaults to["base_game", "standalone_expansion"], which already excludes individual expansion entities from results. Effective mode searches through expansions but returns base games.
For most queries, effective mode adds modest overhead. For very broad queries (no other filters, large result sets), it may be noticeably slower. Consumers should use effective mode intentionally when expansion-aware results are needed, not as a default for every query.
Real-World Examples
Six scenarios demonstrating the filtering system in practice. Each includes the natural-language question, the API request, and an annotated response.
Example 1: Game Night – 4 Players, 90 Minutes, Medium Weight
Scenario: Four friends have 90 minutes. They want medium-weight cooperative games, no space themes.
Request:
POST /games/search HTTP/1.1
Content-Type: application/json
{
"players": 4,
"playtime_max": 90,
"playtime_source": "community",
"weight_min": 2.0,
"weight_max": 3.5,
"mechanics": ["cooperative"],
"theme_not": ["space"],
"sort": "rating_desc",
"limit": 5
}
Response:
{
"data": [
{
"id": "01967b3c-5a00-7000-8000-000000000010",
"slug": "pandemic",
"name": "Pandemic",
"type": "base_game",
"year_published": 2008,
"min_players": 2,
"max_players": 4,
"community_max_playtime": 60,
"weight": 2.42,
"rating": 7.58
},
{
"id": "01967b3c-5a00-7000-8000-000000000011",
"slug": "the-crew-mission-deep-sea",
"name": "The Crew: Mission Deep Sea",
"type": "base_game",
"year_published": 2021,
"min_players": 2,
"max_players": 5,
"community_max_playtime": 25,
"weight": 2.07,
"rating": 8.06
},
{
"id": "01967b3c-5a00-7000-8000-000000000012",
"slug": "mysterium",
"name": "Mysterium",
"type": "base_game",
"year_published": 2015,
"min_players": 2,
"max_players": 7,
"community_max_playtime": 50,
"weight": 2.18,
"rating": 7.22
}
],
"meta": {
"total": 87,
"limit": 5,
"cursor": "eyJyYXRpbmciOjcuMjIsImlkIjoiMDE5NjdiM2MifQ==",
"filters_applied": {
"players": 4,
"playtime_max": 90,
"playtime_source": "community",
"weight_min": 2.0,
"weight_max": 3.5,
"mechanics": ["cooperative"],
"theme_not": ["space"]
},
"sort": "rating_desc",
"effective": false
}
}
Why this works: Community play time is used (playtime_source: "community"), so games that publishers claim are “60 minutes” but actually take 110 are excluded. The space theme exclusion removes games like Beyond the Sun or Xia that might otherwise match mechanically.
Example 2: Solo Gaming – Heavy, Engine-Building, Recent
Scenario: A solo player wants heavy engine-building games published in the last 3 years.
Request:
POST /games/search HTTP/1.1
Content-Type: application/json
{
"players": 1,
"top_at": 1,
"weight_min": 3.5,
"mechanics_all": ["engine-building", "solo"],
"year_min": 2023,
"sort": "weight_desc",
"limit": 10
}
Response:
{
"data": [
{
"id": "01967b3c-5a00-7000-8000-000000000020",
"slug": "ark-nova",
"name": "Ark Nova",
"type": "base_game",
"year_published": 2023,
"min_players": 1,
"max_players": 4,
"weight": 3.73,
"rating": 8.52
}
],
"meta": {
"total": 12,
"limit": 10,
"cursor": null,
"filters_applied": {
"players": 1,
"top_at": 1,
"weight_min": 3.5,
"mechanics_all": ["engine-building", "solo"],
"year_min": 2023
},
"sort": "weight_desc",
"effective": false
}
}
Why top_at matters: players=1 finds games that support solo play, but many of those are mediocre solo experiences (a multiplayer game with a tacked-on solo mode). top_at=1 narrows to games the community rates highly at 1 player (above the high threshold, e.g., 4.0+/5), dramatically improving recommendation quality.
Example 3: Large Group – Party Games for 6-8 People
Scenario: A group of 7 wants light party games under 30 minutes.
Request:
POST /games/search HTTP/1.1
Content-Type: application/json
{
"players": 7,
"playtime_max": 30,
"weight_max": 1.5,
"category": ["party"],
"sort": "rating_desc",
"limit": 10
}
Response:
{
"data": [
{
"id": "01967b3c-5a00-7000-8000-000000000030",
"slug": "codenames",
"name": "Codenames",
"type": "base_game",
"year_published": 2015,
"min_players": 2,
"max_players": 8,
"max_playtime": 15,
"weight": 1.31,
"rating": 7.58
},
{
"id": "01967b3c-5a00-7000-8000-000000000031",
"slug": "wavelength",
"name": "Wavelength",
"type": "base_game",
"year_published": 2019,
"min_players": 2,
"max_players": 12,
"max_playtime": 30,
"weight": 1.08,
"rating": 7.42
},
{
"id": "01967b3c-5a00-7000-8000-000000000032",
"slug": "just-one",
"name": "Just One",
"type": "base_game",
"year_published": 2018,
"min_players": 3,
"max_players": 7,
"max_playtime": 20,
"weight": 1.00,
"rating": 7.54
}
],
"meta": {
"total": 43,
"limit": 10,
"cursor": "eyJyYXRpbmciOjcuNTQsImlkIjoiMDE5NjdiM2MifQ==",
"filters_applied": {
"players": 7,
"playtime_max": 30,
"weight_max": 1.5,
"category": ["party"]
},
"sort": "rating_desc",
"effective": false
}
}
Example 4: Effective Mode – 6-Player Games with Expansions
Scenario: A group of 6 wants strategy games. They are willing to buy expansions if needed.
Request:
POST /games/search HTTP/1.1
Content-Type: application/json
{
"players": 6,
"effective": true,
"weight_min": 2.5,
"weight_max": 4.6,
"category": ["strategy"],
"sort": "rating_desc",
"limit": 5
}
Response:
{
"data": [
{
"id": "01967b3c-5a00-7000-8000-000000000001",
"slug": "spirit-island",
"name": "Spirit Island",
"type": "base_game",
"year_published": 2017,
"min_players": 1,
"max_players": 4,
"weight": 4.08,
"rating": 8.31,
"matched_via": {
"type": "expansion_combination",
"expansions": [
{ "slug": "jagged-earth", "name": "Jagged Earth" }
],
"effective_properties": {
"min_players": 1,
"max_players": 6,
"weight": 4.52
},
"resolution_tier": 1
}
},
{
"id": "01967b3c-5a00-7000-8000-000000000040",
"slug": "scythe",
"name": "Scythe",
"type": "base_game",
"year_published": 2016,
"min_players": 1,
"max_players": 5,
"weight": 3.42,
"rating": 8.22,
"matched_via": {
"type": "expansion_combination",
"expansions": [
{ "slug": "scythe-invaders-from-afar", "name": "Scythe: Invaders from Afar" }
],
"effective_properties": {
"min_players": 1,
"max_players": 7,
"weight": 3.45
},
"resolution_tier": 1
}
}
],
"meta": {
"total": 28,
"limit": 5,
"cursor": "eyJyYXRpbmciOjguMjIsImlkIjoiMDE5NjdiM2MifQ==",
"filters_applied": {
"players": 6,
"effective": true,
"weight_min": 2.5,
"weight_max": 4.6,
"category": ["strategy"]
},
"sort": "rating_desc",
"effective": true
}
}
Key insight: Neither Spirit Island (1-4p) nor Scythe (1-5p) supports 6 players in base form. Both appear because effective mode found expansion combinations that reach 6. The matched_via object tells the consumer exactly which expansions to buy.
Example 5: Designer Deep Dive – All Uwe Rosenberg Games
Scenario: A fan wants to explore all medium-to-heavy Uwe Rosenberg games sorted by year.
Request:
POST /games/search HTTP/1.1
Content-Type: application/json
{
"designer": ["uwe-rosenberg"],
"weight_min": 2.5,
"type": ["base_game"],
"sort": "year_desc",
"limit": 20
}
Response:
{
"data": [
{
"id": "01967b3c-5a00-7000-8000-000000000050",
"slug": "a-feast-for-odin",
"name": "A Feast for Odin",
"type": "base_game",
"year_published": 2016,
"weight": 3.86,
"rating": 8.16
},
{
"id": "01967b3c-5a00-7000-8000-000000000051",
"slug": "caverna",
"name": "Caverna: The Cave Farmers",
"type": "base_game",
"year_published": 2013,
"weight": 3.78,
"rating": 7.90
},
{
"id": "01967b3c-5a00-7000-8000-000000000052",
"slug": "agricola",
"name": "Agricola",
"type": "base_game",
"year_published": 2007,
"weight": 3.64,
"rating": 7.94
}
],
"meta": {
"total": 14,
"limit": 20,
"cursor": null,
"filters_applied": {
"designer": ["uwe-rosenberg"],
"weight_min": 2.5,
"type": ["base_game"]
},
"sort": "year_desc",
"effective": false
}
}
Note: type: ["base_game"] excludes Rosenberg’s expansions and promos, focusing only on his standalone designs. Without this filter, results would include expansion entries for Agricola, Caverna, etc.
Example 6: First-Time Play – “We Have 2 Hours and Nobody Knows the Game”
Scenario: A group of 3 wants to try a new cooperative game tonight. They have 2 hours. They have never played whatever game they pick, so the first-play time needs to fit within 2 hours.
Request:
POST /games/search HTTP/1.1
Content-Type: application/json
{
"players": 3,
"playtime_max": 120,
"playtime_source": "community",
"playtime_experience": "first_play",
"weight_min": 2.0,
"weight_max": 3.5,
"mechanics": ["cooperative"],
"sort": "rating_desc",
"limit": 5,
"include": ["experience_playtime"]
}
Response:
{
"data": [
{
"id": "01967b3c-5a00-7000-8000-000000000010",
"slug": "pandemic",
"name": "Pandemic",
"type": "base_game",
"year_published": 2008,
"min_players": 2,
"max_players": 4,
"community_max_playtime": 60,
"weight": 2.42,
"rating": 7.58,
"experience_playtime": {
"levels": [
{ "experience_level": "first_play", "median_minutes": 75, "total_reports": 891 },
{ "experience_level": "experienced", "median_minutes": 50, "total_reports": 2104 }
],
"multipliers": { "first_play": 1.50, "learning": 1.20, "experienced": 1.0, "expert": 0.90 },
"sufficient_data": true
}
},
{
"id": "01967b3c-5a00-7000-8000-000000000080",
"slug": "horrified",
"name": "Horrified",
"type": "base_game",
"year_published": 2019,
"min_players": 1,
"max_players": 5,
"community_max_playtime": 55,
"weight": 2.02,
"rating": 7.32,
"experience_playtime": {
"levels": [
{ "experience_level": "first_play", "median_minutes": 65, "total_reports": 234 },
{ "experience_level": "experienced", "median_minutes": 45, "total_reports": 567 }
],
"multipliers": { "first_play": 1.44, "learning": 1.15, "experienced": 1.0, "expert": 0.92 },
"sufficient_data": true
}
}
],
"meta": {
"total": 34,
"limit": 5,
"cursor": "eyJyYXRpbmciOjcuMzIsImlkIjoiMDE5NjdiM2MifQ==",
"filters_applied": {
"players": 3,
"playtime_max": 120,
"playtime_source": "community",
"playtime_experience": "first_play",
"weight_min": 2.0,
"weight_max": 3.5,
"mechanics": ["cooperative"]
},
"sort": "rating_desc",
"effective": false
}
}
Key insight: Spirit Island is NOT in these results. Its community max playtime is 150 minutes, and adjusted for first play (× 1.57) that is 235 minutes – well over the 2-hour budget. Pandemic fits because its first-play adjusted time (60 × 1.50 = 90 min) is within 120. The experience_playtime include shows the consumer exactly what to expect: “Pandemic will take about 75 minutes for your first game.”
Without experience adjustment, this query would have returned Spirit Island (community max 150 min > 120, still excluded) but would have included games whose experienced play time is 80 minutes but whose first play realistically takes 130+ minutes – setting up the group for a game that runs over their time budget.
Search Endpoint
The primary filtering endpoint is POST /games/search. It accepts a JSON body containing all filter parameters, sort order, and pagination controls. A GET /games endpoint with query parameters exists for simple lookups, but the POST endpoint is the recommended interface for multi-dimensional queries.
Why POST for Search
Complex filter queries involve arrays, nested parameters, and boolean logic that map poorly to URL query strings:
mechanics=["cooperative","hand-management"]&mechanics_not=["dice-rolling"]is awkward as query parameters and ambiguous across HTTP client implementations.- URL length limits (practical limit around 2000 characters) can be exceeded by queries with many filter values.
- JSON bodies have well-defined semantics for arrays, nulls, and nested objects.
The POST /games/search endpoint is not creating a resource – it is a query. This follows the established pattern used by Elasticsearch, Algolia, and other search APIs. The endpoint returns 200 OK, not 201 Created.
Request Schema
{
// Dimension 1: Rating & Confidence
"rating_min": null,
"rating_max": null,
"min_rating_votes": null,
"confidence_min": null,
// Dimension 2: Weight
"weight_min": 2.0,
"weight_max": 3.5,
"effective_weight": false,
"min_weight_votes": null,
// Dimension 3: Player Count
"players": 4,
"players_min": null,
"players_max": null,
"top_at": null,
"recommended_at": null,
"effective": false, // search across expansion combinations
"include_integrations": false, // include integrates_with products in effective mode
"edition": null, // edition slug for edition-specific resolution
// Dimension 4: Play Time
"playtime_min": null,
"playtime_max": 90,
"community_playtime_min": null,
"community_playtime_max": null,
"playtime_source": "publisher", // "publisher" or "community"
"playtime_experience": null, // "first_play", "learning", "experienced", "expert"
// Dimension 5: Age Recommendation
"age_min": null,
"age_max": null,
"community_age_min": null,
"community_age_max": null,
"age_source": "publisher", // "publisher" or "community"
// Dimension 6: Game Type & Mechanics
"type": ["base_game", "standalone_expansion"],
"mode": null, // shorthand: "all", "playable", "addons"
"mechanics": ["cooperative"], // OR logic
"mechanics_all": null, // AND logic
"mechanics_not": null, // exclusion
// Dimension 7: Theme
"theme": null, // OR logic
"theme_not": ["space"], // exclusion
// Dimension 8: Metadata
"designer": null,
"publisher": null,
"family": null,
"category": null,
"year_min": null,
"year_max": null,
"language_dependence": null,
// Dimension 9: Corpus & Archetype (aspirational)
"corpus": null,
"corpus_rating_min": null,
// Resource embedding
"include": null, // e.g., ["mechanics", "experience_playtime"]
// Pagination & sort
"sort": "rating_desc",
"limit": 20,
"cursor": null
}
All fields are optional. Omitted or null fields are treated as inactive filters (no constraint on that dimension).
Field Types
| Field | Type | Default | Description |
|---|---|---|---|
rating_min | float | null | Minimum community rating (1.0-10.0) |
rating_max | float | null | Maximum community rating (1.0-10.0) |
min_rating_votes | integer | null | Minimum rating vote count |
confidence_min | float | null | Minimum rating confidence score (0.0-1.0) |
weight_min | float | null | Minimum complexity weight (1.0-5.0) |
weight_max | float | null | Maximum complexity weight (1.0-5.0) |
effective_weight | boolean | false | Use expansion-modified weights |
min_weight_votes | integer | null | Minimum weight vote count |
players | integer | null | Exact player count filter |
players_min | integer | null | Minimum player count range |
players_max | integer | null | Maximum player count range |
top_at | integer | null | Community highly-rated player count |
recommended_at | integer | null | Community acceptable player count |
effective | boolean | false | Enable expansion-aware search |
include_integrations | boolean | false | Include integrates_with products in effective mode combinations |
edition | string | null | Edition slug or ID for edition-specific property resolution. See Effective Mode. |
playtime_min | integer | null | Minimum play time in minutes |
playtime_max | integer | null | Maximum play time in minutes |
community_playtime_min | integer | null | Minimum community-reported play time |
community_playtime_max | integer | null | Maximum community-reported play time |
playtime_source | string | “publisher” | Time source for playtime_min/max: “publisher” or “community” |
playtime_experience | string | null | Experience adjustment: “first_play”, “learning”, “experienced”, “expert” |
age_min | integer | null | Minimum age (source-toggled) |
age_max | integer | null | Maximum age (source-toggled) |
community_age_min | integer | null | Minimum community-suggested age |
community_age_max | integer | null | Maximum community-suggested age |
age_source | string | “publisher” | Age source for age_min/max: “publisher” or “community” |
type | string[] | [“base_game”, “standalone_expansion”] | Game type filter |
mode | string | null | Shorthand: “all”, “playable”, “addons” |
mechanics | string[] | null | Any of these mechanics (OR) |
mechanics_all | string[] | null | All of these mechanics (AND) |
mechanics_not | string[] | null | None of these mechanics (NOT) |
theme | string[] | null | Any of these themes (OR) |
theme_not | string[] | null | None of these themes (NOT) |
designer | string[] | null | Any of these designers (slug or UUID) |
publisher | string[] | null | Any of these publishers (slug or UUID) |
family | string[] | null | Any of these families (slug or UUID) |
category | string[] | null | Any of these categories (slug) |
year_min | integer | null | Published in or after this year |
year_max | integer | null | Published in or before this year |
language_dependence | string[] | null | Filter by text dependence level |
corpus | string | null | Filter by player corpus (aspirational). See Dimensions: Corpus & Archetype. |
corpus_rating_min | float | null | Minimum rating within the specified corpus (aspirational). |
include | string[] | null | Embed related resources in response (e.g., ["mechanics", "experience_playtime"]). See ADR-0017. |
sort | string | “rating_desc” | Sort order (see Dimensions) |
limit | integer | 20 | Results per page (1-100) |
cursor | string | null | Pagination cursor from previous response |
Dimensional weight filters: Implementations that support the detailed weight mode may also accept per-dimension weight filters (weight_rules_complexity_min, weight_strategic_depth_min, etc.). See Dimensions: Dimensional Weight Filters.
GET Equivalent
For simple queries, GET /games accepts a subset of parameters as query strings:
GET /games?players=4&playtime_max=90&mechanics=cooperative&sort=rating_desc&limit=20
Array parameters use comma-separated values in GET:
GET /games?mechanics=cooperative,hand-management&theme_not=space
The GET endpoint supports all the same parameters but is less ergonomic for complex queries. Use POST when:
- You have more than 3-4 active filters
- You use array parameters with multiple values
- You use both inclusion and exclusion parameters on the same dimension
- Your query might exceed URL length limits
Response Schema
See Response Metadata for the full response structure.
Validation
The API validates the request body and returns RFC 9457 Problem Details for validation errors:
{
"type": "https://api.opentabletop.org/errors/validation",
"title": "Validation Error",
"status": 422,
"detail": "weight_min must be between 1.0 and 5.0",
"errors": [
{
"field": "weight_min",
"message": "must be between 1.0 and 5.0",
"value": 6.0
}
]
}
Validation Rules
rating_minandrating_maxmust be between 1.0 and 10.0confidence_minmust be between 0.0 and 1.0weight_minandweight_maxmust be between 1.0 and 5.0limitmust be between 1 and 100typevalues must be valid game type discriminatorsmechanics,theme,categoryvalues must be valid slugs in the taxonomyplaytime_sourcemust be “publisher” or “community”age_sourcemust be “publisher” or “community”playtime_experiencemust be “first_play”, “learning”, “experienced”, or “expert”language_dependencevalues must be valid dependence levelssortmust be a valid sort key- Unknown fields are ignored (forward compatibility)
Response Metadata
Every search response includes a meta object alongside the data array. The metadata provides pagination controls, filter echo-back, and aggregate information about the result set.
Response Structure
{
"data": [
{ "...game object..." },
{ "...game object..." }
],
"meta": {
"total": 87,
"limit": 20,
"cursor": "eyJyYXRpbmciOjcuNTQsImlkIjoiMDE5NjdiM2MtNWEwMC03MDAwIn0=",
"filters_applied": {
"players": 4,
"playtime_max": 90,
"playtime_source": "community",
"weight_min": 2.0,
"weight_max": 3.5,
"mechanics": ["cooperative"],
"theme_not": ["space"]
},
"sort": "rating_desc",
"effective": false,
"include_integrations": false
}
}
Meta Fields
| Field | Type | Description |
|---|---|---|
total | integer | Total number of games matching the filter across all pages. |
limit | integer | The page size used for this response (echoed from request or default). |
cursor | string or null | Opaque pagination cursor. Pass this as the cursor parameter in the next request to get the next page. null means this is the last page. |
filters_applied | object | Echo of all active filters. Only includes non-null, non-default parameters. |
sort | string | The sort order used. |
effective | boolean | Whether effective mode was active for this query. |
include_integrations | boolean | Whether integrates_with combinations were included in effective mode. Only meaningful when effective is true. |
FilterSummary (filters_applied)
The filters_applied object echoes back every filter that was active in the query, using the same field names as the request body. This serves two purposes:
- Debugging. Consumers can verify that their query was interpreted correctly. If you sent
playtime_source: "community"but seeplaytime_source: "publisher"in the response, something went wrong. - Permalink construction. A frontend can reconstruct the exact query from the response metadata, enabling shareable filter URLs.
Only active filters appear. Default values and null parameters are omitted:
// Request with these defaults:
{
"type": ["base_game", "standalone_expansion"],
"sort": "rating_desc",
"limit": 20
}
// Response filters_applied is empty because all values are defaults:
{
"filters_applied": {}
}
// Request with explicit non-default type:
{
"type": ["base_game", "expansion"],
"sort": "rating_desc"
}
// Response includes type because it differs from default:
{
"filters_applied": {
"type": ["base_game", "expansion"]
}
}
Pagination
The cursor field implements keyset pagination. See Pagination for details.
- First request: omit
cursor(or set tonull). - Next page: pass the
cursorvalue from the previous response. - Last page:
cursorisnull– no more results.
The cursor is an opaque string. Do not parse, construct, or modify it. Its format may change between API versions.
Total Count
The total field provides the count of all matching games, not just those on the current page. This enables UI patterns like “Showing 1-20 of 87 results.”
Computing exact totals can be expensive for very broad queries. The specification allows implementations to return an estimate for totals above a configurable threshold (default: 10,000). If the total is estimated, a boolean total_estimated field is set to true:
{
"meta": {
"total": 15200,
"total_estimated": true
}
}
For most filtered queries, the total is small enough to be exact.
Future: Facet Counts
A planned extension to the response metadata is facet counts – aggregate counts of how many results match each value of a given dimension. For example:
{
"meta": {
"facets": {
"mechanics": {
"cooperative": 87,
"hand-management": 42,
"deck-building": 31,
"area-control": 18
},
"weight_histogram": {
"1.0-1.5": 3,
"1.5-2.0": 12,
"2.0-2.5": 28,
"2.5-3.0": 25,
"3.0-3.5": 15,
"3.5-4.0": 4
}
}
}
}
Facet counts enable progressive filtering UIs where the user sees how many results each additional filter would produce. This is on the statistics roadmap but not in the initial specification.
Pillar 3: Statistical Foundation
The third pillar of OpenTabletop is a commitment: raw data is a first-class output. Every opinion-based data point in the system – player count polls, weight votes, community play times, expansion property modifications – is available as exportable, analyzable data. The specification does not lock consumers into one algorithm for “best player count” or one definition of “weight.” It provides the underlying distributions and lets consumers decide.
Why This Matters
Board game data is full of derived values that obscure the underlying reality:
-
BGG’s “best player count” is a single number derived from poll data using an undocumented algorithm. You cannot see the vote distribution. You cannot apply a different threshold. You cannot ask “is this game controversial at 4 players?” because the raw votes are not exposed.
-
BGG’s “weight” is an average. You cannot see whether the average of 3.5 comes from a bimodal distribution (half say 2.0, half say 5.0) or a consensus (everyone says 3.5). These are very different signals about a game’s complexity.
-
BGG’s “geek rating” applies a Bayesian average that penalizes games with few votes. The formula is not public. You cannot recompute it, adjust it, or replace it with your own ranking system.
OpenTabletop’s position is that these derived values are useful but they belong in application logic, not in the data specification. The specification provides the raw inputs – vote distributions, individual data points, exportable collections – and lets applications build whatever derived values serve their users.
What the Statistical Foundation Provides
1. Raw Vote Distributions
Every poll-based value exposes the full vote breakdown:
- Player count polls: For each supported player count, the exact number of Best, Recommended, and Not Recommended votes. See Data Structures.
- Weight votes: The distribution of weight votes (how many people voted 1.0, how many voted 2.0, etc.), not just the average.
- Rating votes: The distribution of rating votes across the 1-10 scale.
2. Expansion Delta Data
Property modifications and expansion combinations are not just used internally for effective mode filtering – they are queryable and exportable entities. A data scientist can download all property modifications to analyze patterns like:
- “How much does adding an expansion typically increase play time?”
- “Do expansions tend to increase or decrease the best player count?”
- “Is there a correlation between expansion weight delta and expansion rating?”
3. Bulk Export
The /export endpoints provide full dataset downloads in machine-friendly formats. Every filter dimension available in the search API is also available in the export API, so you can export “all cooperative games published since 2020 with their player count polls” rather than downloading the entire database.
See Data Export for format specifications.
Design Principles
Transparency. Every derived value in the API has a documented derivation path back to raw data. If the API returns best_player_counts: [2, 3], you can look at the PlayerCountPoll data and verify the derivation yourself.
Reproducibility. Given the same raw data and the same algorithm, you should get the same derived values. The specification documents the default derivation algorithms but does not require implementations to use them.
Exportability. Data locked in an API is only half-useful. The export system ensures that researchers, analysts, and alternative implementations can work with the full dataset offline.
Composability. Export uses the same filter dimensions as search. You do not need a separate query language or data model for analytics.
Data Structures
The statistical foundation is built on specific data structures that capture community opinion as raw distributions rather than pre-computed summaries.
Player Count Ratings
The player count rating is the most important statistical data structure in the specification. For each game and each supported player count, it records a numeric community rating on a 1-5 scale – not categorical buckets, but real numbers that support standard statistical analysis. See Player Count Model for the full design rationale.
Schema
| Field | Type | Description |
|---|---|---|
game_id | UUIDv7 | The game being evaluated |
player_count | integer | The specific player count (1, 2, 3, …) |
average_rating | float (1.0-5.0) | Mean community rating at this player count |
rating_count | integer | Number of votes at this player count |
rating_stddev | float | Standard deviation (consensus vs polarization) |
Example: Lost Ruins of Arnak
| Player Count | Avg Rating | Votes | Std Dev | Signal |
|---|---|---|---|---|
| 1 | 3.4 / 5 | 876 | 1.0 | Decent solo – AI opponent works but lacks tension |
| 2 | 4.5 / 5 | 1,234 | 0.6 | Strong consensus – the sweet spot |
| 3 | 4.2 / 5 | 1,089 | 0.7 | Great, slightly more downtime than 2 |
| 4 | 3.1 / 5 | 745 | 1.1 | Acceptable but downtime becomes noticeable |
From this raw data, a consumer can derive:
- Highest rated at 2 (4.5/5 with tight consensus at std dev 0.6).
- Well-rated at 2-3: Both above 4.0, the “highly rated” threshold.
- 4 is acceptable but divisive (3.1/5 with high std dev) – the added downtime between turns divides opinion.
- Solo is middling (3.4/5) – the AI opponent is functional but lacks the competitive tension of multiplayer.
Different applications set different thresholds. A hardcore strategy app might set “recommended” at 4.0+; a family app might set it at 3.0+. The raw numeric data supports any interpretation – no fixed categories constrain the analysis.
Accessing Rating Data
GET /games/lost-ruins-of-arnak/player-count-ratings
{
"game_id": "01967b3c-5a00-7000-8000-000000000095",
"ratings": [
{ "player_count": 1, "average_rating": 3.4, "rating_count": 876, "rating_stddev": 1.0 },
{ "player_count": 2, "average_rating": 4.5, "rating_count": 1234, "rating_stddev": 0.6 },
{ "player_count": 3, "average_rating": 4.2, "rating_count": 1089, "rating_stddev": 0.7 },
{ "player_count": 4, "average_rating": 3.1, "rating_count": 745, "rating_stddev": 1.1 }
]
}
BGG Legacy Data
For data migrated from BoardGameGeek, the PlayerCountPollLegacy schema preserves BGG’s three-tier voting model (Best / Recommended / Not Recommended). This data is available via the API but is not the native model – it is maintained for backward compatibility and transparency during migration. Legacy three-tier data can be converted to approximate numeric ratings for unified querying. See Player Count Model: BGG Legacy Data.
Weight Votes
The weight field on a Game is an average. The weight vote distribution provides the underlying data.
Schema
| Field | Type | Description |
|---|---|---|
game_id | UUIDv7 | The game being evaluated |
votes | object | Map of weight value (string) to vote count |
total_votes | integer | Sum of all votes |
average | float | Computed average (same as Game.weight) |
Example: Great Western Trail
Great Western Trail’s weight is concentrated in the 3.5-4.0 range, reflecting strong agreement that the game sits firmly in the “heavy-medium” band. The tight cluster suggests voters – despite varying experience levels – perceive the game’s complexity similarly. The small tail of 1.0-2.0 votes may reflect voters who found the cattle-market loop more intuitive than the weight suggests.
A bimodal distribution (many 2.0 votes and many 4.0 votes) would suggest the game’s complexity is debated, which is useful information that an average hides.
Dimensional Weight Data
Implementations that support the detailed weight mode store per-dimension vote distributions – rules complexity, strategic depth, decision density, cognitive load, fiddliness, and game length – each rated independently on a 1-5 scale. These per-dimension distributions are exportable alongside the composite weight distribution, enabling analyses like “which games have high strategic depth but low fiddliness?” that a single composite number cannot answer.
Accessing Weight Distribution
GET /games/great-western-trail/weight-votes
{
"game_id": "01967b3c-5a00-7000-8000-000000000090",
"votes": {
"1.0": 5,
"1.5": 3,
"2.0": 18,
"2.5": 67,
"3.0": 312,
"3.5": 1489,
"4.0": 2134,
"4.5": 876,
"5.0": 142
},
"total_votes": 5046,
"average": 3.72
}
Rating Distribution
The average_rating on a Game is a single number that hides the distribution shape. The rating distribution exposes the full histogram of voter opinion, a confidence score, and standard deviation. See Rating Model for the four-layer rating architecture.
Schema
| Field | Type | Description |
|---|---|---|
game_id | UUIDv7 | The game being evaluated |
average_rating | float (1-10) | Arithmetic mean of normalized ratings |
rating_count | integer | Total number of ratings |
rating_distribution | integer[10] | Histogram: vote count at each 1-10 bucket |
rating_stddev | float | Standard deviation of the distribution |
confidence | float (0.0-1.0) | Spec-defined confidence score |
Example: Dune: Imperium
The distribution reveals what the average hides:
- Bell curve centered at 8-9 – strong consensus that this is a top-tier game.
- Low std dev (1.38) – voters agree. Compare to a brigaded game where std dev exceeds 3.5.
- High confidence (0.86) – large sample, tight consensus. This number is trustworthy.
A bimodal distribution (peaks at 3 and 9) would indicate a polarizing game – some love it, some hate it. The average might be 6.0 in both cases, but the distribution shape tells a completely different story.
The confidence score (0.0-1.0) synthesizes sample size, distribution shape, and deviation from the global mean into a single trust signal. See Rating Model: Confidence Score for the formula, and the pre-release brigading case study for a real-world example where confidence correctly flags a meaningless rating.
Accessing Rating Distribution
GET /games/dune-imperium/rating-distribution
{
"game_id": "01967b3c-5a00-7000-8000-000000000091",
"average_rating": 8.32,
"rating_count": 42876,
"rating_distribution": [98, 112, 245, 502, 1234, 3456, 8912, 14567, 10234, 3516],
"rating_stddev": 1.38,
"confidence": 0.86
}
Community Age Poll
The community age poll captures voter recommendations for the minimum appropriate age for a game. Unlike player count ratings (which use a numeric scale), age polls are simple: voters pick the minimum age they would suggest. The distribution reveals how the community’s view compares to the publisher’s box rating. See Age Recommendation Model.
Schema
| Field | Type | Description |
|---|---|---|
game_id | UUIDv7 | The game being evaluated |
suggested_age | integer | The minimum age voters selected |
vote_count | integer | Number of voters who selected this age |
The Game entity includes a derived field:
| Field | Type | Description |
|---|---|---|
community_suggested_age | integer (nullable) | Median of all age votes |
Example: Viticulture
The publisher rates Viticulture at 13+. The community sees it differently:
| Suggested Age | Votes |
|---|---|
| 8 | 34 |
| 10 | 189 |
| 12 | 312 |
| 14 | 87 |
| 16 | 11 |
The community suggested age is 12 – one year lower than the publisher’s box rating. Despite the wine theme, the gameplay is abstract enough (place workers, collect resources, fill orders) that voters consider the mechanics accessible to a 12-year-old. The publisher’s conservative 13+ likely reflects the thematic subject matter rather than mechanical complexity. The gap between “thematically appropriate” and “mechanically capable” is exactly the kind of nuance the community poll captures.
Accessing Age Poll Data
GET /games/viticulture/age-poll
{
"game_id": "01967b3c-5a00-7000-8000-000000000092",
"community_suggested_age": 12,
"votes": [
{ "suggested_age": 8, "vote_count": 34 },
{ "suggested_age": 10, "vote_count": 189 },
{ "suggested_age": 12, "vote_count": 312 },
{ "suggested_age": 14, "vote_count": 87 },
{ "suggested_age": 16, "vote_count": 11 }
],
"total_votes": 633
}
Community Play Time Data
Community-reported play times are derived from individual play logs. The statistical foundation exposes aggregate data.
Schema
| Field | Type | Description |
|---|---|---|
game_id | UUIDv7 | The game |
total_plays | integer | Number of plays with reported duration |
min_reported | integer | Minimum reported play time (minutes) |
max_reported | integer | Maximum reported play time (minutes) |
median | integer | Median play time (minutes) |
p10 | integer | 10th percentile |
p25 | integer | 25th percentile |
p75 | integer | 75th percentile |
p90 | integer | 90th percentile |
by_player_count | object | Median play time broken down by player count |
Example: Concordia
This data shows what the publisher’s single estimate cannot capture:
- The publisher says 100 minutes. The community median is 105 – unusually close for a strategy game.
- 2-player games are fast (70 min median) – Concordia scales well downward.
- 5-player games take over twice as long as 2-player (155 vs 70 min) – the scaling factor is dramatic.
- The 90th percentile is 160 minutes – some groups spend nearly 3 hours.
- The per-player-count breakdown reveals that player count is the dominant factor in play time.
Accessing Play Time Data
GET /games/concordia/community-playtime
{
"game_id": "01967b3c-5a00-7000-8000-000000000093",
"total_plays": 6234,
"min_reported": 40,
"max_reported": 240,
"median": 105,
"p10": 65,
"p25": 80,
"p75": 130,
"p90": 160,
"by_player_count": {
"2": { "median": 70, "plays": 2845 },
"3": { "median": 100, "plays": 1987 },
"4": { "median": 125, "plays": 1134 },
"5": { "median": 155, "plays": 268 }
}
}
Experience Playtime Poll
The experience playtime poll captures community-reported play times bucketed by player experience level. Like PlayerCountRating, it stores raw distributions rather than pre-computed summaries. See ADR-0034.
Schema
| Field | Type | Description |
|---|---|---|
game_id | UUIDv7 | The game being evaluated |
experience_level | string | first_play, learning, experienced, or expert |
median_minutes | integer | Median reported play time for this level |
min_minutes | integer | 10th percentile play time |
max_minutes | integer | 90th percentile play time |
total_reports | integer | Number of contributing play reports |
Example: Gloomhaven: Jaws of the Lion
| Level | Median | p10 | p90 | Reports |
|---|---|---|---|---|
| first_play | 120 min | 90 min | 180 min | 456 |
| learning | 90 min | 65 min | 130 min | 712 |
| experienced | 70 min | 50 min | 100 min | 1,534 |
| expert | 55 min | 40 min | 80 min | 389 |
From this data, multipliers are derived: first_play = 120/70 = 1.71, expert = 55/70 = 0.79. This tells consumers that a first scenario of Gloomhaven: Jaws of the Lion takes 71% longer than an experienced play – the tutorial scenarios help, but the card combo system and enemy AI rules create a steep initial learning curve. By expert level, optimized play and familiar monster patterns cut session time significantly.
Accessing Experience Playtime Data
GET /games/gloomhaven-jaws-of-the-lion/experience-playtime
{
"game_id": "01967b3c-5a00-7000-8000-000000000094",
"levels": [
{ "experience_level": "first_play", "median_minutes": 120, "min_minutes": 90, "max_minutes": 180, "total_reports": 456 },
{ "experience_level": "learning", "median_minutes": 90, "min_minutes": 65, "max_minutes": 130, "total_reports": 712 },
{ "experience_level": "experienced", "median_minutes": 70, "min_minutes": 50, "max_minutes": 100, "total_reports": 1534 },
{ "experience_level": "expert", "median_minutes": 55, "min_minutes": 40, "max_minutes": 80, "total_reports": 389 }
],
"multipliers": { "first_play": 1.71, "learning": 1.29, "experienced": 1.0, "expert": 0.79 },
"sufficient_data": true,
"total_reports": 3091
}
Analytical Questions Enabled
- Which games have the steepest learning curve? Sort by first_play multiplier descending – games where the gap between first play and experienced play is largest.
- Which games are “easy to learn”? Low first_play multiplier means play time barely changes with experience.
- Expert speedrun potential: Which games have the lowest expert multiplier, suggesting the most room for optimization?
- Data sufficiency: Which games have enough experience-tagged play logs to produce reliable multipliers?
Expansion Deltas as Analyzable Data
Property modifications and expansion combinations are not just internal data for effective mode – they are exportable entities. See Data Export for how to bulk-download this data.
Interesting analyses enabled by this data:
- Average weight increase per expansion: Do expansions tend to make games more complex?
- Player count expansion patterns: How often do expansions increase the max player count? By how much?
- Playtime inflation: Do expansions make games longer? By what percentage?
- Best-at shift: Does adding expansions change which player counts are considered best?
These questions are unanswerable without structured, exportable expansion delta data. OpenTabletop makes them trivial.
Data Export
The export system provides bulk access to data from a conforming implementation in machine-friendly formats. It uses the same filter dimensions as the search API, so you can export targeted slices of the dataset rather than downloading everything.
Export Endpoint
GET /export/games?format=jsonl&mechanics=cooperative&year_min=2020
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
format | string | jsonl | Output format: jsonl or csv |
include | string[] | ["base"] | Data to include (see below) |
| All search filters | – | – | Same parameters as POST /games/search |
Include Options
| Value | Description |
|---|---|
base | Core game fields (id, slug, name, type, players, playtime, weight, rating, confidence, community_suggested_age) |
taxonomy | Mechanics, categories, themes, families for each game |
people | Designers, artists, publishers |
polls | Player count ratings (numeric 1-5 per count, with average, vote count, and std dev) |
rating_distribution | Rating histogram (1-10 buckets), std dev, and confidence score |
weight_votes | Weight vote distribution |
experience_playtime | Experience-bucketed playtime data (first_play, learning, experienced, expert multipliers) |
age_polls | Community age recommendation polls |
playtime_stats | Community play time statistics |
editions | Edition metadata and edition deltas (ADR-0035) |
identifiers | External cross-reference IDs |
relationships | GameRelationship edges |
deltas | PropertyModification and ExpansionCombination data |
all | Everything above |
GET /export/games?format=jsonl&include=base,polls,taxonomy&weight_min=3.0
JSON Lines Format
Each line is a self-contained JSON object representing one game with all requested includes:
{"id":"01967b3c-5a00-7000-8000-000000000096","slug":"barrage","name":"Barrage","type":"base_game","min_players":1,"max_players":4,"weight":4.02,"rating":8.12,"mechanics":["worker-placement","area-control","engine-building"],"categories":["strategy","economic"],"player_count_ratings":[{"player_count":1,"average_rating":2.8,"rating_count":312,"rating_stddev":1.2},{"player_count":2,"average_rating":3.9,"rating_count":534,"rating_stddev":0.8},{"player_count":3,"average_rating":4.6,"rating_count":687,"rating_stddev":0.5},{"player_count":4,"average_rating":4.4,"rating_count":498,"rating_stddev":0.7}]}
{"id":"01967b3c-5a00-7000-8000-000000000097","slug":"castles-of-burgundy","name":"The Castles of Burgundy","type":"base_game","min_players":1,"max_players":4,"weight":3.00,"rating":8.28,"mechanics":["dice-rolling","drafting","engine-building"],"categories":["strategy"],"player_count_ratings":[{"player_count":1,"average_rating":3.2,"rating_count":456,"rating_stddev":1.0},{"player_count":2,"average_rating":4.7,"rating_count":1823,"rating_stddev":0.4},{"player_count":3,"average_rating":3.8,"rating_count":987,"rating_stddev":0.8},{"player_count":4,"average_rating":3.3,"rating_count":612,"rating_stddev":0.9}]}
JSON Lines (.jsonl) is chosen because:
- Each line can be parsed independently – streaming and parallel processing are trivial.
- Appending new records does not require modifying existing data.
- Tools like
jq,pandas, andduckdbhandle JSON Lines natively. - It is the de facto standard for data engineering pipelines.
CSV Format
Flat tabular export for spreadsheet users and simple analysis:
id,slug,name,type,min_players,max_players,weight,rating,confidence,mechanics,categories
01967b3c-5a00-7000-8000-000000000096,barrage,Barrage,base_game,1,4,4.02,8.12,0.81,"worker-placement|area-control|engine-building","strategy|economic"
01967b3c-5a00-7000-8000-000000000097,castles-of-burgundy,The Castles of Burgundy,base_game,1,4,3.00,8.28,0.85,"dice-rolling|drafting|engine-building","strategy"
CSV limitations:
- Array fields are pipe-delimited within a single column (
cooperative|hand-management). - Nested data (polls, vote distributions) is flattened or excluded.
- CSV export supports
include=base,taxonomybut nested includes likepollsproduce one row per poll entry (game x player_count), not one row per game.
For anything beyond basic game metadata, JSON Lines is the recommended format.
ExportManifest
Every export response begins with a manifest header (in JSON Lines format) or is accompanied by a manifest endpoint:
GET /export/manifest?format=jsonl&mechanics=cooperative&year_min=2020
{
"export_id": "01967b3c-7000-7000-8000-000000000099",
"format": "jsonl",
"filters_applied": {
"mechanics": ["cooperative"],
"year_min": 2020
},
"includes": ["base", "polls", "taxonomy"],
"total_games": 342,
"generated_at": "2026-03-12T10:00:00Z",
"spec_version": "1.0.0",
"checksum": "sha256:a1b2c3d4e5f6..."
}
The manifest provides:
- Reproducibility: The exact filters and includes used, so someone can regenerate the export later.
- Integrity: A SHA-256 checksum of the export data.
- Versioning: The spec version the data conforms to.
- Metadata: Total count, timestamp, unique export ID.
Streaming
Export responses are streamed. The server begins sending data as soon as the first row is ready, without buffering the entire result set. This means:
- Large exports (100k+ games) work without timeouts.
- Clients can begin processing before the download completes.
- Memory usage is bounded on both client and server.
The Content-Type header is application/x-ndjson for JSON Lines and text/csv for CSV. The Transfer-Encoding is chunked.
Rate Limiting
Export endpoints are rate-limited separately from search endpoints. The default limits:
- 10 export requests per hour per API key
- Maximum 100,000 games per export (use filters to stay under)
- No limit on export size in bytes (but larger exports take longer)
These are the specification’s recommended defaults. Implementations may adjust limits based on their infrastructure.
For full-dataset exports exceeding the per-request limit, use the cursor parameter to paginate through the export in batches.
Use Cases
- Academic research: Export all games with weight votes and player count polls for a study on complexity perception.
- Alternative ranking systems: Export ratings and vote counts to compute your own Bayesian ranking.
- Collection management: Export your owned games (filtered by a list of IDs) with full metadata for a local database.
- Data journalism: Export games by year to analyze trends in board game design.
- Machine learning: Export the full dataset as training data for a recommendation engine.
Trend Analysis
The board game hobby is a living ecosystem. Mechanics rise and fall in popularity. Rating consensus shifts as communities mature. Publishing channels transform – Kickstarter barely existed for board games before 2012; by 2020 it was the dominant funding model for mid-tier publishers. The OpenTabletop specification provides the schema and endpoints to make these dynamics queryable.
Two Types of Trends
Trend analysis splits into two fundamentally different problems, each requiring different data and different endpoints.
Cross-Sectional Trends
Cross-sectional trends aggregate existing game data over year_published. They answer questions about the population of games at each point in time:
- “How many cooperative games were published per year?”
- “What’s the average weight of games published each decade?”
- “What percentage of 2024 games support solo play?”
- “When did deck-building peak as a mechanic?”
These require no new data collection. Every game already has a year_published, mechanics, weight, min_players, and other filterable fields. Cross-sectional trends are pure aggregation – grouping and counting over existing records.
Note that cross-sectional trends reflect what is in the dataset, not necessarily all games published in a given year. If an implementation’s data skews toward popular or well-known games, the trends will reflect that subset. Similarly, metrics like “average weight published per year” depend on who is rating those games – see Data Provenance & Bias.
Longitudinal Trends
Longitudinal trends track the same entities over time. They answer questions about how individual games or rankings change, as perceived by the measuring population:
- “What was BGG #1 in 2019?”
- “How did Gloomhaven’s rating change from 2018 to 2024?”
- “When did Brass: Birmingham overtake Terraforming Mars?”
- “Do legacy games’ ratings decline after the campaign ends?”
These require periodic snapshots – point-in-time captures of each game’s rating, weight, rank, and activity metrics. This is new data that does not exist in the current model. The GameSnapshot schema (see ADR-0036) defines the snapshot format; implementations choose the snapshot frequency (monthly, quarterly, or yearly) based on their resources.
flowchart LR
subgraph "Cross-Sectional"
EXISTING["Existing Game Data<br/><i>year_published, mechanics,<br/>weight, players, ...</i>"]
AGG["Aggregate Endpoints<br/><i>GROUP BY year</i>"]
EXISTING --> AGG
end
subgraph "Longitudinal"
SNAP["GameSnapshot Data<br/><i>Periodic captures of<br/>rating, rank, plays, ...</i>"]
HIST["History Endpoints<br/><i>Time series per game</i>"]
SNAP --> HIST
end
style EXISTING fill:#388e3c,color:#fff
style AGG fill:#1976d2,color:#fff
style SNAP fill:#f57c00,color:#fff
style HIST fill:#1976d2,color:#fff
Cross-Sectional Endpoints
These endpoints aggregate over existing game data. All accept the standard filter dimensions (mechanic, category, theme, player count, weight range) so you can ask “how did cooperative games’ average weight change over time?” – not just “how did all games’ average weight change?”
Publication Trends
GET /statistics/trends/publications?group_by=year&mechanic=cooperative&year_min=2000
{
"data": [
{ "period": 2000, "game_count": 12, "avg_weight": 2.9, "avg_rating": 6.8 },
{ "period": 2008, "game_count": 34, "avg_weight": 2.7, "avg_rating": 7.2 },
{ "period": 2017, "game_count": 187, "avg_weight": 2.6, "avg_rating": 7.0 },
{ "period": 2024, "game_count": 342, "avg_weight": 2.8, "avg_rating": 7.1 }
]
}
The 2008 inflection point is visible – Pandemic’s release triggered an explosion of cooperative game design. By 2024, cooperative games are nearly 30x more common than in 2000.
Mechanic Adoption
GET /statistics/trends/mechanics?year_min=2000&limit=5
{
"data": [
{ "period": 2007, "mechanic": "deck-building", "game_count": 0, "pct_of_period": 0.0 },
{ "period": 2008, "mechanic": "deck-building", "game_count": 1, "pct_of_period": 0.1 },
{ "period": 2012, "mechanic": "deck-building", "game_count": 87, "pct_of_period": 4.2 },
{ "period": 2020, "mechanic": "deck-building", "game_count": 156, "pct_of_period": 3.8 }
]
}
Dominion launched in 2008 as a single game. By 2012, deck-building was 4.2% of all published games. These mechanic adoption curves map directly to the phylogenetic model – every mechanic’s origin_game and origin_year in the taxonomy data marks the start of its adoption curve.
Weight Distribution
GET /statistics/trends/weight?group_by=year&scope=top100
{
"data": [
{ "period": 2010, "avg_weight": 3.4, "median_weight": 3.3, "weight_p25": 2.8, "weight_p75": 3.9 },
{ "period": 2020, "avg_weight": 3.1, "median_weight": 3.0, "weight_p25": 2.5, "weight_p75": 3.6 },
{ "period": 2025, "avg_weight": 3.2, "median_weight": 3.1, "weight_p25": 2.6, "weight_p75": 3.7 }
]
}
The scope parameter controls which games are included:
top100– Current top 100 ranked games, grouped by publication yearpublished– All games published in each year (the broadest view)all– All games in the dataset, regardless of rank or year
Player Count Trends
GET /statistics/trends/player-count?year_min=2015
{
"data": [
{ "period": 2015, "avg_min_players": 1.8, "avg_max_players": 4.1, "solo_support_pct": 22.4 },
{ "period": 2020, "avg_min_players": 1.4, "avg_max_players": 4.2, "solo_support_pct": 38.5 },
{ "period": 2025, "avg_min_players": 1.3, "avg_max_players": 4.3, "solo_support_pct": 45.2 }
]
}
Solo support nearly doubled from 2015 to 2025 – a clear industry-wide shift toward accommodating solo gamers. The 2020 inflection is particularly sharp: the COVID-19 pandemic drove an explosion of solo and small-group game design as lockdowns removed the regular game night. Kickstarter campaigns advertising solo modes surged during 2020-2021, and publishers who had treated solo as an afterthought began designing for it from the start. The trend persisted well after lockdowns ended, suggesting the pandemic accelerated a shift that was already underway rather than creating a temporary spike.
Longitudinal Endpoints
These endpoints query GameSnapshot data. They require implementations to collect periodic snapshots (see ADR-0036). Longitudinal trend quality improves over time as more snapshots accumulate.
Game History
GET /games/spirit-island/history?metric=rating&granularity=yearly
{
"game": { "id": "01912f4c-...", "slug": "spirit-island", "name": "Spirit Island" },
"metric": "rating",
"granularity": "yearly",
"data": [
{ "date": "2018-01-01", "average_rating": 8.05, "rating_count": 8420 },
{ "date": "2019-01-01", "average_rating": 8.18, "rating_count": 14230 },
{ "date": "2020-01-01", "average_rating": 8.25, "rating_count": 21500 },
{ "date": "2024-01-01", "average_rating": 8.31, "rating_count": 27842 }
]
}
Spirit Island’s rating has been steadily climbing – a sign of a game with strong staying power that the community values more over time, not less.
Ranking History
GET /statistics/rankings/history?scope=overall&top=10&date=2020-01-01
{
"scope": "overall",
"date": "2020-01-01",
"data": [
{ "rank": 1, "game_slug": "gloomhaven", "bayes_rating": 8.85 },
{ "rank": 2, "game_slug": "pandemic-legacy-season-1", "bayes_rating": 8.62 },
{ "rank": 3, "game_slug": "brass-birmingham", "bayes_rating": 8.59 }
]
}
Ranking Transitions
GET /statistics/rankings/transitions?scope=overall&top=10&year_min=2018&year_max=2025
{
"scope": "overall",
"top": 10,
"from": "2018-01-01",
"to": "2025-01-01",
"entered": [
{ "game_slug": "brass-birmingham", "entered_rank": 3, "entered_date": "2019-01-01", "current_rank": 1 },
{ "game_slug": "ark-nova", "entered_rank": 6, "entered_date": "2022-01-01", "current_rank": 5 }
],
"exited": [
{ "game_slug": "7-wonders-duel", "exited_rank": 9, "exited_date": "2023-01-01", "peak_rank": 4 }
],
"stable": [
{ "game_slug": "gloomhaven", "rank_2018": 1, "rank_2025": 2 }
]
}
The response groups games into three arrays: entered (climbed into the top N during the window), exited (fell out), and stable (remained throughout). This is the data behind narratives like “the era of legacy games” or “the heavy euro resurgence.”
Funding Source
To track the impact of crowdfunding on the hobby, the specification includes an optional funding_source field on the Game entity:
funding_source:
enum: [retail, kickstarter, gamefound, backerkit, self_published, other]
This enables queries like:
GET /statistics/trends/publications?group_by=year&funding_source=kickstarter&year_min=2012
{
"data": [
{ "period": 2012, "game_count": 42, "kickstarter_pct": 1.8 },
{ "period": 2016, "game_count": 285, "kickstarter_pct": 8.4 },
{ "period": 2020, "game_count": 612, "kickstarter_pct": 14.7 },
{ "period": 2024, "game_count": 489, "kickstarter_pct": 11.2 },
{ "period": 2025, "game_count": 310, "kickstarter_pct": 7.8 },
{ "period": 2026, "game_count": 274, "kickstarter_pct": 6.9 }
]
}
Kickstarter-funded games peaked around 2020 at nearly 15% of all published titles. The slight decline by 2024 reflects the rise of Gamefound and BackerKit as alternatives – which is why the funding_source enum includes multiple crowdfunding platforms rather than treating “crowdfunded” as a single category.
The 2025 US tariffs on Chinese imports add another dimension to this data. The board game industry relies heavily on Chinese manufacturing – publishers like Panda Game Manufacturing, LongPack, and WinGo produce the majority of hobbyist titles. Tariffs raise the per-unit cost of component-heavy games (miniatures, custom inserts, large box formats) disproportionately, and crowdfunded games are especially exposed: backer pricing is locked months or years before fulfillment, leaving publishers to absorb cost increases they could not have predicted. Cross-referencing funding_source=kickstarter with the weight distribution endpoint would reveal whether crowdfunded games shift toward lighter component profiles in response, or whether publishers absorb the cost and pass it to backers through higher pledge tiers. The publication trends endpoint itself may show a volume dip in 2025-2026 as smaller publishers delay or cancel projects – a pattern visible in the game_count field the same way the COVID-19 pandemic’s effects appeared in the player count data above.
Connecting Trends to Taxonomy
The cross-sectional trend endpoints connect naturally to the taxonomy’s phylogenetic model. Every mechanic in the controlled vocabulary has an origin_game, origin_year, and ancestor_slugs – the game that created or codified that mechanic, and the lineage it descended from. These speciation events are the inflection points visible in trend data, and the parent-child relationships reveal how design innovations propagate through the hobby.
| Year | Origin Game | Mechanic Created | Parent Mechanic | Trend Impact |
|---|---|---|---|---|
| -3000 | Backgammon | Dice Rolling | (root) | Randomness foundation; spawns push-your-luck, roll-and-write, dice placement |
| 1200 | Dominoes | Tile Placement | (root) | Spatial mechanic root; 6 children spanning 800 years |
| 1890 | Rummy | Hand Management | (root) | Largest mechanic family – 9 children including deck building, card drafting, tableau building |
| 1956 | Yahtzee | Roll and Write | Dice Rolling | Slow fuse – dormant for decades until the modern wave |
| 1980 | Can’t Stop | Push Your Luck | Dice Rolling | First voluntary-risk mechanic distinct from pure dice rolling |
| 1992 | Modern Art | Auction / Bidding | Trading and Negotiation | Parent of 5 subtypes (English, Dutch, sealed bid, turn order, once around) |
| 2002 | Puerto Rico | Action Selection | Action Points | Bridge mechanic between action points and worker placement |
| 2004 | San Juan | Tableau Building | Hand Management | Cards-as-permanent-engine pattern |
| 2005 | Caylus | Worker Placement | Action Selection | Euro game renaissance; spawns dice placement, bumping variants |
| 2008 | Dominion | Deck Building | Hand Management | New mechanic family; spawns bag building, pool building |
| 2008 | Pandemic | Cooperative (modern) | (root) | Cooperative game explosion |
| 2010 | Alien Frontiers | Dice Placement | Worker Placement + Dice Rolling | Hybrid – two parent lineages converge |
| 2011 | Risk Legacy | Legacy | (root) | Entirely new game category |
| 2012 | Keyflower | Worker Placement with Bumping | Worker Placement | Variant that removes permanent blocking |
| 2017 | Azul | Pattern Building | Tile Placement + Set Collection | Spatial set-collection hybrid |
| 2018 | Welcome To… | Roll-and-Write (modern) | Roll and Write | Accessible game surge; revives a 1956-era mechanic |
Lineage Waves
When a speciation event creates a new mechanic, the parent’s trend curve often shifts simultaneously. Hand Management (Rummy, 1890) has 9 children in the taxonomy. When Deck Building (Dominion, 2008) branched off, it did not replace hand management – both curves grew, but deck building’s was steeper. The /statistics/trends/mechanics endpoint can overlay parent and child mechanic adoption curves to visualize this branching pattern. Querying with a parent=hand-management parameter would return the adoption curves for all nine descendants, showing when each branch emerged and how quickly it grew.
Convergent Evolution
Some mechanics descend from two parent lineages. Dice Placement (Alien Frontiers, 2010) combines Worker Placement’s blocking-and-scarcity with Dice Rolling’s randomness. Pattern Building (Azul, 2017) merges Tile Placement’s spatial reasoning with Set Collection’s combinatorial goals. These convergence points appear in trend data as inflection points where two previously independent curves develop a correlated descendant – a signal that designers are cross-pollinating between established mechanic families.
Dormancy and Revival
Roll and Write originated with Yahtzee in 1956 but was largely dormant as a design space for decades. The modern roll-and-write wave (circa 2018, anchored by Welcome To…) revived the mechanic and spawned an explosion of “flip-and-write” and “roll-and-write” games. The trend data would show near-zero roll-and-write publications from 1960 to 2015, then a steep adoption curve. The taxonomy captures the origin; the trend endpoint captures the revival. Cross-referencing both tells the full lifecycle story – and identifies which other dormant mechanics might be candidates for a similar revival.
The taxonomy data provides the what (which game created which mechanic and where it sits in the tree); the trend endpoints provide the so what (how did the hobby change as a result). Together, they let you query “show me all children of worker-placement and their adoption curves” – connecting the phylogenetic tree directly to observable data.
Trends and the Data Model
Several data model refinements in Pillar 1 and Pillar 2 create new dimensions for trend analysis beyond the basic publication counts and averages shown above.
Rating Confidence Trends
The rating model introduces a confidence score (0.0-1.0) that combines sample size, distribution shape, and deviation from the global mean. Confidence is itself a trendable metric – a newly released game starts with low confidence that stabilizes as votes accumulate. The Game History endpoint accepts metric=confidence alongside metric=rating. Rating polarization (bimodal distributions flagged by high standard deviation) is another trendable signal: “are games becoming more polarizing over time, or is consensus strengthening?”
Dimensional Weight Trends
The weight model supports an optional dimensional breakdown: rules complexity, strategic depth, decision density, cognitive load, fiddliness, and game length. Cross-sectional weight trends could break down by dimension, answering questions like “are modern games getting more strategically deep, or just fiddlier?” The documented complexity bias (heavy games averaging ~2.5 rating points higher than light games of similar quality) is itself a trendable hypothesis – is the bias stable over time, or is it narrowing as the voter population diversifies?
Player Count Sentiment Trends
The player count model uses numeric 1-5 per-count ratings rather than the legacy best/recommended/not-recommended categories. This enables richer longitudinal analysis: tracking how a game’s per-count sentiment shifts over time (e.g., does a game’s solo rating improve as the community develops solo strategies?). Cross-sectionally, the industry-wide “solo friendliness” curve can be measured with finer granularity than the binary solo_support_pct shown above – instead, “average solo rating of games published per year” captures the quality of solo support, not just its presence.
Experience-Bucketed Playtime Trends
The experience-adjusted playtime model defines four experience levels (first play, learning, experienced, expert) with per-game multipliers. Trend queries accept a playtime_experience parameter, answering “how has the average first-play time of published games changed over the decade?” The gap between first-play and expert-play times is itself a trendable metric – are modern games getting better at reducing the first-play penalty, or are they front-loading more complexity?
Data Provenance
All trend data inherits the biases documented in Data Provenance & Bias. Cross-sectional trends reflect what is in the dataset, which may skew toward popular and well-known games (see the note in Cross-Sectional Trends above). Longitudinal trends compound this with a temporal dimension: early snapshots capture the community as it was (smaller, more homogeneous); later snapshots reflect an evolving and growing population. A shift in average weight over time could reflect changing game design or a changing voter population – trend consumers should consider both interpretations.
Design Decisions
See ADR-0036 for the full design rationale, including why periodic snapshots were chosen over event sourcing, and the trade-offs between snapshot granularity options.
Statistics Roadmap
The statistical foundation in the initial specification provides raw distributions, bulk export, and transparent derivations. The roadmap below describes planned extensions that build on this foundation – informed by the expanded data model (rating confidence, dimensional weight, player count sentiment, experience-bucketed playtime, and community signals). These are not commitments – they are directional goals that will go through the RFC governance process before being added to the specification.
Near-Term: Cross-Sectional Trend Endpoints
Status: Planned for v1.0
Cross-sectional trends aggregate existing game data over year_published – no new data collection required. Four endpoints:
GET /statistics/trends/publications– Games published per period, with average weight and rating. Filterable by mechanic, category, theme, funding source.GET /statistics/trends/mechanics– Per-mechanic adoption curves (count and percentage of games per year). Tied to the taxonomy’s phylogenetic model.GET /statistics/trends/weight– Weight distribution over time (mean, median, percentiles). Scoped to top-100, all published, or full dataset.GET /statistics/trends/player-count– Player count ranges and solo support percentage over time.
All trend endpoints compose with the standard filter dimensions. The expanded data model enables additional trend dimensions: rating confidence trends, dimensional weight breakdowns, per-count player sentiment curves, and experience-adjusted playtime trends. See Trend Analysis for worked examples with JSON payloads, and ADR-0036 for the design rationale.
Near-Term: Parquet Export
Status: Planned for v1.1
JSON Lines and CSV cover the common cases, but data engineering workflows increasingly rely on columnar formats for analytical queries. Apache Parquet provides:
- Columnar compression: 5-10x smaller than JSON Lines for numerical data (vote counts, ratings, weights).
- Predicate pushdown: Query engines (DuckDB, Spark, BigQuery) can skip irrelevant columns and row groups without reading the entire file.
- Type safety: Schema is embedded in the file. Integers are integers, not strings that happen to contain digits.
- Ecosystem support: Parquet is readable by Pandas, Polars, R, DuckDB, Spark, Snowflake, BigQuery, Athena, and virtually every modern data tool.
The Parquet export will use the same /export/games endpoint with format=parquet. The include parameter (documented in Data Export) controls which nested structures are populated. Full schema:
games.parquet
├── id (string, UUID)
├── slug (string)
├── name (string)
├── type (string, enum)
├── year_published (int32)
├── min_players (int32)
├── max_players (int32)
├── min_playtime (int32)
├── max_playtime (int32)
├── community_min_playtime (int32, nullable)
├── community_max_playtime (int32, nullable)
├── weight (float32)
├── weight_votes (int32)
├── average_rating (float32)
├── rating_count (int32)
├── rating_distribution (fixed_size_list<int32>[10])
├── rating_stddev (float32)
├── rating_confidence (float32)
├── rank_overall (int32, nullable)
├── community_suggested_age (int32, nullable)
├── owner_count (int32, nullable)
├── wishlist_count (int32, nullable)
├── total_plays (int32, nullable)
├── funding_source (string, nullable)
├── mechanics (list<string>)
├── categories (list<string>)
├── themes (list<string>)
├── player_count_ratings (list<struct>)
│ ├── player_count (int32)
│ ├── average_rating (float32)
│ ├── rating_count (int32)
│ └── rating_stddev (float32)
└── experience_playtime (struct, nullable)
├── levels (list<struct>)
│ ├── experience_level (string, enum)
│ ├── median_minutes (int32)
│ ├── p10_minutes (int32)
│ ├── p90_minutes (int32)
│ └── report_count (int32)
└── multipliers (struct, nullable)
├── first_play (float32)
├── learning (float32)
├── experienced (float32)
└── expert (float32)
Key differences from earlier drafts: player_count_ratings uses the ADR-0043 numeric 1-5 model (average rating, count, stddev per player count), replacing the legacy BGG three-tier fields. Rating data is split into distribution, confidence, and stddev fields rather than a single rating float. Community signals (owner_count, wishlist_count, total_plays) and experience-bucketed playtime are included as first-class columns.
Nested structures (ratings, taxonomy lists, experience playtime) are stored as Parquet nested types, not flattened. This preserves the one-row-per-game structure while keeping relational data accessible to predicate pushdown.
Near-Term: Longitudinal Snapshots
Status: Planned for v1.1
Longitudinal trends track the same entities over time. Unlike cross-sectional trends, they require new data – periodic GameSnapshot records capturing a game’s rating, weight, ranking, play count, and owner count at a specific date. Three endpoints:
GET /games/{id}/history– Time series of a single game’s metrics over time.GET /statistics/rankings/history– Historical ranking snapshots at a specific date.GET /statistics/rankings/transitions– Games entering and exiting the top N over a date range.
See Trend Analysis for worked examples with JSON payloads. The GameSnapshot schema (spec/schemas/GameSnapshot.yaml) captures average_rating, bayes_rating, rating_count, weight, weight_votes, rank_overall, rank_by_category, play_count_period, and owner_count.
Storage Considerations
Snapshot frequency is implementation-defined:
| Frequency | Rows/year (100k games) | Storage | Granularity |
|---|---|---|---|
| Monthly | 1.2M | ~500 MB | Best for short-term trends |
| Quarterly | 400k | ~170 MB | Good balance of cost and detail |
| Yearly | 100k | ~40 MB | Sufficient for long-term analysis |
Historical data before an implementation begins collecting snapshots is not available unless backfilled from external sources. Trend quality improves with history length.
Mid-Term: Correlation APIs
Status: Under discussion
Pre-computed correlations between game properties, exposed as read-only, cacheable API endpoints updated periodically (not real-time). These provide the kind of aggregate analysis that currently requires downloading the full dataset and computing locally.
Mechanic Co-occurrence
“What mechanics most commonly appear together?”
GET /statistics/correlations/mechanics?mechanic=deck-building&limit=10
{
"mechanic": "deck-building",
"cooccurrences": [
{ "mechanic": "hand-management", "count": 412, "jaccard": 0.38 },
{ "mechanic": "engine-building", "count": 287, "jaccard": 0.26 },
{ "mechanic": "drafting", "count": 198, "jaccard": 0.18 }
]
}
Rating-by-Mechanic
“How have average ratings changed over time for cooperative games?”
GET /statistics/trends/rating?mechanic=cooperative&group_by=year
{
"mechanic": "cooperative",
"data": [
{ "period": 2008, "avg_rating": 6.8, "rating_count": 34, "avg_confidence": 0.42 },
{ "period": 2015, "avg_rating": 7.2, "rating_count": 156, "avg_confidence": 0.68 },
{ "period": 2020, "avg_rating": 7.0, "rating_count": 412, "avg_confidence": 0.61 },
{ "period": 2025, "avg_rating": 7.1, "rating_count": 342, "avg_confidence": 0.58 }
]
}
The 2020 dip in average confidence alongside rising game count reflects a flood of new cooperative titles that have not yet accumulated enough votes to stabilize.
Rating Confidence Correlations
The rating model introduces a confidence score (0.0-1.0). Correlating confidence with other properties reveals data quality patterns:
GET /statistics/correlations/confidence?group_by=funding_source
{
"data": [
{ "funding_source": "retail", "avg_confidence": 0.72, "avg_stddev": 1.4, "game_count": 18420 },
{ "funding_source": "kickstarter", "avg_confidence": 0.48, "avg_stddev": 1.9, "game_count": 3215 },
{ "funding_source": "gamefound", "avg_confidence": 0.41, "avg_stddev": 2.1, "game_count": 870 },
{ "funding_source": "self_published", "avg_confidence": 0.34, "avg_stddev": 2.3, "game_count": 1540 }
]
}
Crowdfunded and self-published games show systematically lower confidence and higher variance – smaller voter populations and possible self-selection from backers who are already invested in the game’s success.
- “Which mechanics or themes are associated with the most polarized ratings?” – high stddev, low confidence.
- “Does rating confidence correlate with publication year?” – newer games have less data, but the rate of convergence varies.
Weight-by-Mechanic
“What is the average weight of games with each mechanic?”
GET /statistics/correlations/weight-by-mechanic
{
"data": [
{ "mechanic": "worker-placement", "avg_weight": 3.21, "game_count": 1847, "weight_stddev": 0.72 },
{ "mechanic": "deck-building", "avg_weight": 2.54, "game_count": 1203, "weight_stddev": 0.68 },
{ "mechanic": "roll-and-write", "avg_weight": 1.82, "game_count": 624, "weight_stddev": 0.51 },
{ "mechanic": "wargame-hex-and-counter", "avg_weight": 3.89, "game_count": 312, "weight_stddev": 0.84 }
]
}
Dimensional Weight Correlations
The weight model supports an optional 6-dimension breakdown (rules complexity, strategic depth, decision density, cognitive load, fiddliness, game length). Correlation endpoints can leverage these dimensions:
GET /statistics/correlations/weight-dimensions?dimension=strategic_depth&sort_by=correlation
{
"dimension": "strategic_depth",
"correlations": [
{ "mechanic": "engine-building", "correlation": 0.74, "avg_dimension_score": 3.8, "game_count": 1420 },
{ "mechanic": "worker-placement", "correlation": 0.71, "avg_dimension_score": 3.6, "game_count": 1847 },
{ "mechanic": "auction-bidding", "correlation": 0.65, "avg_dimension_score": 3.3, "game_count": 890 },
{ "mechanic": "roll-and-write", "correlation": 0.31, "avg_dimension_score": 2.1, "game_count": 624 }
]
}
- “Which mechanics correlate with high strategic depth but low fiddliness?” – the “elegant complexity” query.
- “Which weight dimension correlates most strongly with overall rating?” – testing whether strategic depth or rules complexity drives the documented complexity bias.
Experience Playtime Correlations
The experience-adjusted playtime model captures per-game learning curves. Correlating multipliers with game properties answers:
GET /statistics/correlations/experience-curve?sort_by=first_play_multiplier&order=desc
{
"data": [
{ "mechanic": "legacy", "avg_first_play_multiplier": 1.82, "avg_weight": 3.4, "game_count": 87 },
{ "mechanic": "engine-building", "avg_first_play_multiplier": 1.65, "avg_weight": 3.1, "game_count": 1420 },
{ "mechanic": "worker-placement", "avg_first_play_multiplier": 1.52, "avg_weight": 3.2, "game_count": 1847 },
{ "mechanic": "roll-and-write", "avg_first_play_multiplier": 1.18, "avg_weight": 1.8, "game_count": 624 }
]
}
Legacy games have the steepest first-play penalty (1.82x) despite moderate weight – the campaign structure means first sessions include significant overhead that does not recur. Roll-and-write games have the flattest curve, confirming that low-fiddliness mechanics translate directly to faster onboarding.
Community Engagement Correlations
Community signals (owner_count, wishlist_count, total_plays) enable engagement analysis:
GET /statistics/correlations/engagement?metric=plays_per_owner&sort_by=desc
{
"metric": "plays_per_owner",
"data": [
{ "game_slug": "codenames", "plays_per_owner": 18.4, "owner_count": 82100, "total_plays": 1510640 },
{ "game_slug": "7-wonders", "plays_per_owner": 12.7, "owner_count": 71200, "total_plays": 904240 },
{ "game_slug": "terraforming-mars", "plays_per_owner": 8.3, "owner_count": 94500, "total_plays": 784350 },
{ "game_slug": "gloomhaven", "plays_per_owner": 4.1, "owner_count": 68400, "total_plays": 280440 }
]
}
Codenames at 18.4 plays per owner versus Gloomhaven at 4.1 illustrates the replayability spectrum – party games get replayed constantly while campaign games are played through once. This is a signal that rating alone does not capture.
- “Which wishlisted games convert to purchases fastest?” – wishlist-to-owner velocity.
- “Do lighter games get played more often per owner, or does engagement correlate with weight?”
Long-Term: Recommendation Engine Foundation
Status: Exploratory
The data model and export system provide the raw materials for recommendation engines, but the specification intentionally does not define a recommendation algorithm. Recommendations are subjective and application-specific – “similar games” means different things to different users.
What the specification can provide:
- Feature vectors: A standardized game feature vector that recommendation engines can use as input. The expanded data model provides much richer signals than a simple mechanics-and-weight vector:
- Mechanics bitmap (unchanged)
- Dimensional weight profile (6 independent dimensions, not just the composite score)
- Player count sentiment curve (per-count 1-5 ratings from ADR-0043, not just min/max)
- Rating confidence score (distinguishes well-understood games from noisy or polarized ones)
- Experience playtime multipliers (characterizes the learning curve shape)
- Community engagement signals (plays-per-owner ratio, owner velocity)
- Similarity endpoint: A
/games/{id}/similarendpoint that returns games with high feature-vector similarity, using a documented distance metric (e.g., cosine similarity). Dimensional weight and player count curves provide much richer similarity signals than the earlier mechanics-bitmap approach. - User preference profiles: A schema for expressing user preferences that implementations can use to personalize results. Preferences can now include weight dimension priorities (e.g., “I value strategic depth but dislike fiddliness”), experience-adjusted time constraints (“games that fit in 90 minutes for a first play”), and player count quality thresholds (“best at exactly 2, not just supports 2”).
The key principle: the specification defines the inputs to recommendation (feature vectors, similarity metrics), not the outputs (personalized ranked lists). Implementations are free to build sophisticated recommendation systems on top of the specification’s data.
Long-Term: Data Quality Analytics
Status: Exploratory
Analytics about the data itself – not about games, but about the health and completeness of the dataset. These help the community prioritize curation effort and track data maturity over time.
GET /statistics/data-quality
{
"snapshot_date": "2026-03-01",
"resolution_tiers": {
"games_with_expansions": 8420,
"tier_1_explicit_pct": 12.4,
"tier_2_computed_pct": 31.2,
"tier_3_base_only_pct": 56.4
},
"rating_confidence": {
"above_0_7_pct": 34.8,
"between_0_4_and_0_7_pct": 41.2,
"below_0_4_pct": 24.0
},
"player_count_ratings": {
"native_numeric_pct": 28.6,
"legacy_three_tier_only_pct": 52.1,
"no_data_pct": 19.3
},
"experience_playtime": {
"sufficient_data_pct": 18.4,
"partial_data_pct": 22.7,
"no_data_pct": 58.9
}
}
Each section maps to a curation priority:
- Resolution tier distribution: What percentage of games with expansions have tier 1 (explicit
ExpansionCombination), tier 2 (computed deltas), or tier 3 (base game only) effective-mode data? A dashboard showing “87% of games with expansions have only tier 3 data” motivates contributors to curate combination records. - Rating confidence distribution: What percentage of games have confidence above 0.7? How does this break down by publication decade or game type? Tracks overall data maturity.
- Player count rating coverage: What percentage of games have native numeric per-count ratings (ADR-0043) vs. only legacy three-tier data vs. no player count data at all? Tracks migration progress.
- Experience playtime coverage: What percentage of games have
sufficient_data: truefor experience-bucketed playtime? Which weight tiers or mechanics have the worst coverage?
Contributing to the Roadmap
All roadmap items will go through the RFC process described in the Governance Model. To propose a new statistical feature:
- Open a discussion issue describing the use case and the data required.
- If there is community interest, draft an RFC with the proposed schema, endpoints, and export format.
- The RFC goes through the standard review and approval process.
The statistical foundation is designed to be extended. The core data structures (polls, distributions, deltas) are stable; the analytical endpoints built on top of them are where the roadmap lives.
System Overview
OpenTabletop is a specification-first project. The OpenAPI document is the canonical source of truth; documentation, tooling, and any conforming server or client are derived from or validated against that specification. This page describes the architectural patterns that implementers should follow when building against the spec.
Architecture Diagram
flowchart TD
subgraph Specification
OAS["OpenAPI 3.1 Spec<br/><i>Source of Truth</i>"]
DOCS["API Documentation<br/><i>Redoc / Swagger UI</i>"]
end
subgraph "Implementers"
SERVER["Conforming Servers"]
CLIENTS["Client Libraries<br/><i>generated or hand-written</i>"]
end
subgraph "Consumers"
WEB["Web Applications"]
MOBILE["Mobile Apps"]
DATA["Data Science<br/>Pipelines"]
THIRD["Third-Party<br/>Integrations"]
end
OAS -->|defines contract for| SERVER
OAS -->|generates| DOCS
OAS -->|generates or informs| CLIENTS
WEB --> CLIENTS
MOBILE --> CLIENTS
DATA --> CLIENTS
THIRD --> CLIENTS
CLIENTS --> SERVER
style OAS fill:#7b1fa2,color:#fff
style SERVER fill:#1976d2,color:#fff
style CLIENTS fill:#00796b,color:#fff
Note: The specification is the only artifact this project ships. Servers, clients, and tooling are built by implementers against the spec.
Spec-First Design
The specification is written before any implementation code. The recommended workflow for implementers:
- Read the spec. Endpoints, schemas, examples, and constraints are defined in the OpenAPI document.
- Generate artifacts from the spec: client libraries, documentation, mock servers, and contract tests.
- Implement the server to satisfy the contract. A conforming server should be validated against the spec using contract testing – if the server returns a response that does not match the spec schema, the test fails.
- Track spec evolution. Changes to the spec are driven through the RFC process. Implementations follow the spec, not the other way around.
This ensures the specification remains the single source of truth. Implementations cannot drift from the contract when the contract is tested continuously.
Implementation Guidance
The specification does not mandate a particular language or framework, but the data model and query patterns impose certain architectural constraints. The following recommendations are drawn from the project’s ADRs and reflect the patterns best suited to the spec’s requirements.
Server Technology
A conforming server must evaluate complex multi-dimensional filter queries across large datasets. Implementers should choose a stack that provides:
- Low-latency query evaluation. The filtering engine composes up to six dimensions (player count, play time, weight, mechanics, themes, game mode). Compiled languages or JIT runtimes with efficient memory management are well suited.
- Async I/O. Typical requests fan out to a database and optionally a cache layer. An async runtime avoids blocking threads while waiting on I/O.
- OpenTelemetry support. The spec recommends structured traces, metrics, and logs (see Cloud-Native Design).
Data Store
PostgreSQL is the recommended primary data store. The data model maps naturally to relational tables:
gamestable with indexed columns for every filterable field.game_relationshipstable with foreign keys togames.player_count_pollstable with composite primary key(game_id, player_count).expansion_combinationstable with a JSONB column for the expansion ID set and indexed effective properties.mechanics,categories,themesas controlled vocabulary tables with many-to-many join tables.people,organizationswith role-typed join tables.
Why PostgreSQL:
- The data model is inherently relational. Games have relationships to other games, to people, to organizations, to taxonomy terms. Joins are the natural query pattern.
- PostgreSQL’s query planner handles the multi-dimensional filter queries well with appropriate indexes (GIN for array fields, B-tree for range fields, GiST for full-text search).
- JSONB columns provide flexibility for semi-structured data (expansion combination metadata, export manifests) without sacrificing query performance.
- PostgreSQL is free, open source, and the most widely deployed relational database in the world.
Redis is a recommended optional caching layer for:
- Expensive effective-mode queries that are frequently repeated.
- Export manifests.
- Rate limiting counters.
Redis is not required. A conforming server operates correctly without it, at the cost of higher latency for cache-eligible queries.
Client Libraries
Implementers can generate client libraries from the OpenAPI spec using tools like openapi-generator or openapi-typescript. The generation step ensures completeness (every endpoint and schema is covered); manual refinement adds idiomatic patterns for the target language.
Cloud-Native Design
For implementers. This chapter provides guidance for operators building and deploying a conforming OpenTabletop server. The patterns described here are not mandatory for conformance, but they represent best practices drawn from the project’s ADRs.
A conforming server should be designed for cloud-native deployment from the ground up. This means following the Twelve-Factor App methodology and building to run in containerized environments, orchestrated by Kubernetes or similar platforms.
Deployment Topology
flowchart TD
subgraph "Internet"
CLIENT["API Consumers"]
end
subgraph "Edge"
LB["Load Balancer<br/><i>Cloud provider LB</i>"]
end
subgraph "Kubernetes Cluster"
subgraph "API Tier"
API1["API Pod 1"]
API2["API Pod 2"]
API3["API Pod 3"]
end
subgraph "Cache Tier"
REDIS["Redis<br/><i>Cache + Rate Limiting</i>"]
end
subgraph "Data Tier"
PG_PRIMARY["PostgreSQL Primary<br/><i>Writes</i>"]
PG_REPLICA["PostgreSQL Replica<br/><i>Reads</i>"]
end
subgraph "Observability"
OTEL["OpenTelemetry<br/>Collector"]
PROM["Prometheus"]
LOKI["Loki"]
end
end
CLIENT --> LB
LB --> API1
LB --> API2
LB --> API3
API1 --> REDIS
API2 --> REDIS
API3 --> REDIS
API1 --> PG_REPLICA
API2 --> PG_REPLICA
API3 --> PG_REPLICA
API1 -.->|writes| PG_PRIMARY
PG_PRIMARY -->|replication| PG_REPLICA
API1 --> OTEL
API2 --> OTEL
API3 --> OTEL
OTEL --> PROM
OTEL --> LOKI
style LB fill:#757575,color:#fff
style API1 fill:#1976d2,color:#fff
style API2 fill:#1976d2,color:#fff
style API3 fill:#1976d2,color:#fff
style REDIS fill:#00796b,color:#fff
style PG_PRIMARY fill:#388e3c,color:#fff
style PG_REPLICA fill:#388e3c,color:#fff
style OTEL fill:#f57c00,color:#fff
Twelve-Factor Principles
I. Codebase
One codebase tracked in Git, many deploys (staging, production, developer instances).
II. Dependencies
All dependencies declared in a manifest and lock file (e.g., Cargo.toml, package.json, go.mod). No system-level implicit dependencies. The container image includes everything needed to run.
III. Config
All configuration via environment variables:
| Variable | Description | Example |
|---|---|---|
DATABASE_URL | PostgreSQL connection string | postgres://user:pass@host/db |
REDIS_URL | Redis connection string (optional) | redis://host:6379 |
PORT | HTTP listen port | 8080 |
LOG_LEVEL | Logging verbosity | info |
OTEL_EXPORTER_OTLP_ENDPOINT | OpenTelemetry collector | http://otel:4317 |
RATE_LIMIT_RPS | Requests per second per API key | 100 |
CORS_ORIGINS | Allowed CORS origins | https://app.example.com |
No config files. No application.yml. No settings.toml baked into the image.
IV. Backing Services
PostgreSQL and Redis are attached resources, referenced by URL. Swapping a local PostgreSQL for an RDS instance is a config change, not a code change.
V. Build, Release, Run
The CI pipeline produces a container image (build), tags it with a version (release), and deploys it to Kubernetes (run). These stages are strictly separated.
VI. Processes
The server is a stateless process. No in-memory session state, no local file storage. Multiple instances serve the same traffic behind a load balancer.
VII. Port Binding
The server binds to a port ($PORT) and serves HTTP directly. No app server wrapper required – the application is the HTTP server.
VIII. Concurrency
Horizontal scaling via process count. Need more throughput? Add more pods. An async runtime also scales vertically across CPU cores within a single process.
IX. Disposability
Fast startup (< 1 second). Graceful shutdown on SIGTERM (finish in-flight requests, close database connections). Pods can be killed and restarted at any time without data loss.
X. Dev/Prod Parity
The same Docker image runs in development, staging, and production. Environment variables differentiate. docker compose provides a local stack that mirrors production topology.
XI. Logs
Logs are written to stdout as structured JSON. No log files, no log rotation. The orchestrator (Kubernetes) captures stdout and ships to Loki or your log aggregator of choice.
XII. Admin Processes
Database migrations, data imports, and other admin tasks run as one-off Kubernetes Jobs using the same container image. They are not baked into the server startup.
OpenTelemetry
A conforming server should be instrumented with OpenTelemetry for distributed tracing, metrics, and logs:
Traces: Every HTTP request generates a trace span. Database queries, cache lookups, and filter evaluation are child spans. Consumers can pass a traceparent header (W3C Trace Context) for end-to-end tracing.
Metrics: Request count, latency histograms (p50, p95, p99), active connections, cache hit rate, database query duration. Exposed as Prometheus metrics at /metrics.
Logs: Structured JSON logs with trace ID correlation. Every log line includes the trace ID so you can jump from a log entry to its full request trace.
Container Image
A conforming server should ship as a minimal container image:
- Base:
scratchordistroless– no shell, no package manager, no attack surface. - Binary: A single statically-linked binary or minimal runtime. No unnecessary runtime dependencies.
- Size: Keep images small. Pulls should complete in seconds.
- Health check:
/healthendpoint returns 200 if the server is up and can reach PostgreSQL./readyreturns 200 when the server is ready to accept traffic (connection pool warmed, migrations verified).
Kubernetes Readiness
A conforming server should be designed for Kubernetes deployment with:
- Readiness and liveness probes at
/readyand/health. - Graceful shutdown respecting the termination grace period.
- Resource requests and limits tuned for the query workload (CPU-bound for filter evaluation, memory-bound for connection pools).
- Horizontal Pod Autoscaler based on request latency or CPU utilization.
- PodDisruptionBudget to maintain availability during node maintenance.
Legacy Migration
Adopting the OpenTabletop standard while moving away from BGG’s XML API is a gradual process. The architecture supports a strangler fig pattern: a translation layer that sits between consumers and BGG, progressively routing more traffic to a conforming OpenTabletop server as its dataset grows and the specification stabilizes.
The Strangler Fig Pattern
The strangler fig is a tree that grows around an existing tree, eventually replacing it entirely. In software architecture, it means building a new system alongside an old one and gradually migrating traffic from old to new, with a facade layer that routes requests to the appropriate backend.
flowchart TD
subgraph "Consumers"
APP["Your Application"]
end
subgraph "API Gateway / Facade"
GW["Translation Layer"]
end
subgraph "New System"
OBG["Conforming Server<br/><i>OpenTabletop JSON / REST</i>"]
end
subgraph "Legacy System"
BGG["BGG XML API<br/><i>XML / Undocumented</i>"]
XLAT["XML-to-JSON<br/>Translator"]
BGG --> XLAT
end
APP --> GW
GW -->|"Game data<br/>(migrated)"| OBG
GW -->|"Forum data, images<br/>(not yet migrated)"| XLAT
XLAT -->|"Translated JSON"| GW
style OBG fill:#388e3c,color:#fff
style BGG fill:#d32f2f,color:#fff
style GW fill:#1976d2,color:#fff
style XLAT fill:#f57c00,color:#fff
How It Works
Phase 1: Translation Layer Only
Initially, the facade routes everything to BGG through a translation layer that:
- Accepts OpenTabletop-style JSON requests.
- Translates them to BGG XML API calls.
- Parses the XML response.
- Maps BGG fields to OpenTabletop schema.
- Returns a conformant OpenTabletop JSON response.
This gives consumers a stable, documented API immediately, even before the conforming server has its own data. The translation layer handles:
| BGG Endpoint | OpenTabletop Equivalent | Notes |
|---|---|---|
GET /xmlapi2/thing?id=162886 | GET /games/spirit-island | Game entity mapping |
GET /xmlapi2/search?query=spirit | GET /games?q=spirit | Name search |
GET /xmlapi2/thing?id=162886&stats=1 | GET /games/spirit-island?include=polls,stats | Statistics embedding |
GET /xmlapi2/family?id=39224 | GET /families/spirit-island | Family lookup |
Phase 2: Dual Backend
As the conforming server’s dataset grows (imported from BGG data, community contributions, publisher partnerships), the facade starts routing to the native API for entities that exist:
flowchart LR
REQ["Incoming Request<br/>GET /games/spirit-island"]
CHECK{"Exists in<br/>conforming server?"}
OBG["Conforming Server<br/><i>Native data</i>"]
BGG["BGG Translation<br/><i>Fallback</i>"]
RESP["Response"]
REQ --> CHECK
CHECK -->|Yes| OBG
CHECK -->|No| BGG
OBG --> RESP
BGG --> RESP
style OBG fill:#388e3c,color:#fff
style BGG fill:#f57c00,color:#fff
The routing decision is per-entity: if Spirit Island exists in the conforming server’s database, serve it natively. If an obscure game has not been imported yet, fall back to BGG translation.
Phase 3: Native Only
Once the dataset is comprehensive enough, the BGG translation layer is decommissioned. All requests are served natively from the conforming server. The facade becomes unnecessary and is removed.
Field Mapping
Migrating from BGG’s data model to OpenTabletop’s requires careful field mapping. Key differences:
Game Entity
| BGG XML Field | OpenTabletop Field | Notes |
|---|---|---|
@objectid | identifiers[source=bgg].external_id | BGG ID becomes an external identifier |
name[@type='primary']/@value | name | Primary name |
yearpublished/@value | year_published | Direct mapping |
minplayers/@value | min_players | Direct mapping |
maxplayers/@value | max_players | Direct mapping |
minplaytime/@value | min_playtime | Publisher-stated |
maxplaytime/@value | max_playtime | Publisher-stated |
statistics/ratings/average/@value | rating | Direct mapping |
statistics/ratings/averageweight/@value | weight | Direct mapping |
link[@type='boardgamemechanic'] | mechanics[] | Must map to controlled vocabulary slugs |
link[@type='boardgamecategory'] | categories[] | Must map to controlled vocabulary slugs |
link[@type='boardgamedesigner'] | people[role=designer] | N:M relationship |
link[@type='boardgamepublisher'] | organizations[role=publisher] | N:M relationship |
link[@type='boardgameexpansion'] | relationships[type=expands] | Expansion relationships |
link[@type='boardgameimplementation'] | relationships[type=reimplements] | Reimplementation relationships |
poll[@name='suggested_numplayers'] | PlayerCountPoll | Per-count vote mapping |
| N/A | community_min_playtime | No BGG equivalent; community-sourced |
| N/A | community_max_playtime | No BGG equivalent; community-sourced |
Key Differences
No type discriminator in BGG. BGG does not distinguish base games from expansions at the entity level – you determine this from the presence of expansion links. The OpenTabletop specification defines an explicit type field.
No dual playtime in BGG. BGG stores only publisher-stated play time. Community play time is an OpenTabletop addition.
No property deltas in BGG. BGG has no concept of how expansions change base game properties. This is entirely new in the OpenTabletop specification.
Taxonomy mapping. BGG mechanics and categories are free text. Mapping them to OpenTabletop’s controlled vocabulary requires a maintained mapping table (e.g., BGG “Deck, Bag, and Pool Building” maps to OpenTabletop deck-building).
Data Import
The migration architecture includes a data import pipeline for bulk loading BGG data into a conforming server’s database:
- BGG data dump. Periodic snapshots of BGG data (available through BGG data exports or scraping within rate limits).
- Transformation. Apply the field mapping, resolve taxonomy slugs, generate UUIDv7 identifiers, construct relationships.
- Deduplication. Match entities by BGG ID to avoid duplicates on re-import.
- Enrichment. Add OpenTabletop-specific data (community play times, property deltas, expansion combinations) from community contributions.
- Load. Bulk insert into PostgreSQL.
The import pipeline is idempotent – running it twice with the same input produces the same result. BGG IDs are stored as external identifiers and used as deduplication keys.
For Application Developers
If you are building an application that currently uses the BGG XML API, the migration path is:
- Start using an OpenTabletop client library with the translation layer as the backend. Your code uses the standard API immediately; the translation layer handles BGG communication.
- Map your BGG IDs. Use the identifier lookup endpoint to find OpenTabletop UUIDs for your existing BGG IDs.
- Adopt new features incrementally. Start using effective mode, community play times, and multi-dimensional filtering – features that have no BGG equivalent.
- Remove BGG dependency. Once the conforming server’s dataset covers your needs, point your client at it directly and decommission the translation layer.
See Migrating from BGG for a practical step-by-step guide.
OpenAPI Specification Overview
The OpenTabletop API is defined by an OpenAPI 3.1 specification in the spec/ directory. This spec is the single source of truth – all conforming implementations are derived from it (ADR-0002).
File Organization
The spec is split across multiple files for maintainability, bundled into a single file for distribution:
spec/
├── openapi.yaml # Root document (bundles via $ref)
├── info.yaml # API metadata, contact, license
├── paths/ # One file per endpoint group
│ ├── games.yaml # GET /games (with all filter params)
│ ├── games-{id}.yaml # GET /games/{id}
│ ├── search.yaml # GET /search, POST /games/search
│ └── ...
├── schemas/ # One file per data type
│ ├── Game.yaml
│ ├── ExpansionCombination.yaml
│ ├── PlayerCountPoll.yaml
│ └── ...
├── parameters/ # Grouped by filter dimension
│ ├── player-count-filters.yaml
│ ├── playtime-filters.yaml
│ └── ...
└── examples/ # Concrete data examples
├── game-spirit-island.yaml
└── ...
Naming Conventions
| Type | Convention | Example |
|---|---|---|
| Schema names | PascalCase | ExpansionCombination |
| Path files | kebab-case | games-{id}-effective-properties.yaml |
| Parameter files | kebab-case by dimension | player-count-filters.yaml |
| Enum values | snake_case | base_game, standalone_expansion |
| Query params | snake_case | weight_min, community_playtime_max |
Working with the Spec
# Validate
npx @stoplight/spectral-cli lint spec/openapi.yaml
# Bundle into single file
./scripts/bundle-spec.sh
# View in Swagger UI
npx @redocly/cli preview-docs spec/openapi.yaml
Adding New Components
Use the /generate-openapi-component Claude skill, or follow these steps:
- Create the schema/path/parameter file in the appropriate directory
- Add a
$reftospec/openapi.yaml - Create an example in
spec/examples/if applicable - Run validation to ensure no broken references
Pagination
OpenTabletop uses keyset (cursor) pagination for all list endpoints (ADR-0012).
Why Cursor Pagination
| Approach | Performance at scale | Consistent results | Complexity |
|---|---|---|---|
Offset (?page=50) | Degrades with large offsets | Rows can shift between pages | Low |
| Keyset (cursor) | Constant performance | Consistent | Medium |
| Page number | Same as offset | Same as offset | Low |
Cursor pagination uses an opaque token that encodes the position in the result set. Performance is constant regardless of how deep you paginate.
Request Parameters
| Parameter | Type | Default | Max | Description |
|---|---|---|---|---|
limit | integer | 25 | 100 | Number of items per page |
cursor | string | (none) | – | Opaque cursor from a previous response |
Response Format
{
"data": [
{ "id": "...", "name": "Spirit Island", ... },
{ "id": "...", "name": "Pandemic", ... }
],
"meta": {
"total": 1423,
"next_cursor": "eyJpZCI6IjAxOTM4NWEyLTdj...",
"prev_cursor": "eyJpZCI6IjAxOTM4MmIxLTRh...",
"filters_applied": { ... }
},
"_links": {
"self": { "href": "/v1/games?limit=25" },
"next": { "href": "/v1/games?cursor=eyJpZCI6IjAxOTM4NWEyLTdj...&limit=25" },
"prev": { "href": "/v1/games?cursor=eyJpZCI6IjAxOTM4MmIxLTRh...&limit=25" }
}
}
Usage
# First page
curl "https://api.opentabletop.org/v1/games?limit=25"
# Next page (use next_cursor from previous response)
curl "https://api.opentabletop.org/v1/games?cursor=eyJpZCI6IjAxOTM4NWEyLTdj...&limit=25"
Notes
- Cursors are opaque – do not parse or construct them; they may change format between API versions
- Cursors are stable – using a cursor always returns the next logical page, even if items are added or removed
prev_cursorisnullon the first page;next_cursorisnullon the last page- Filters and sort order are encoded in the cursor – you do not need to repeat them on subsequent pages
Error Handling
All errors use RFC 9457 Problem Details format (ADR-0015).
Error Response Format
{
"type": "https://api.opentabletop.org/errors/not-found",
"title": "Resource Not Found",
"status": 404,
"detail": "No game found with slug 'nonexistent-game'",
"instance": "/v1/games/nonexistent-game"
}
Fields
| Field | Type | Required | Description |
|---|---|---|---|
type | string (URI) | Yes | Error type identifier |
title | string | Yes | Human-readable summary |
status | integer | Yes | HTTP status code |
detail | string | No | Human-readable explanation specific to this occurrence |
instance | string (URI) | No | URI of the request that caused the error |
errors | array | No | Validation error details (see below) |
Validation Errors
When request parameters fail validation, the response includes an errors array:
{
"type": "https://api.opentabletop.org/errors/validation",
"title": "Validation Error",
"status": 400,
"detail": "One or more request parameters are invalid",
"instance": "/v1/games?weight_min=6.0",
"errors": [
{
"field": "weight_min",
"message": "must be between 1.0 and 5.0",
"value": "6.0"
}
]
}
Error Types
| Type URI | Status | Meaning |
|---|---|---|
.../errors/not-found | 404 | Resource does not exist |
.../errors/validation | 400 | Request parameters invalid |
.../errors/rate-limited | 429 | Rate limit exceeded |
.../errors/unauthorized | 401 | API key required but not provided |
.../errors/forbidden | 403 | API key lacks permission |
.../errors/internal | 500 | Server error |
Rate Limit Errors
Rate limit responses include standard headers:
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1710288000
Retry-After: 45
{
"type": "https://api.opentabletop.org/errors/rate-limited",
"title": "Rate Limit Exceeded",
"status": 429,
"detail": "You have exceeded the rate limit of 60 requests per minute"
}
Architecture Decision Records
This section documents all Architecture Decision Records (ADRs) for the OpenTabletop project, following the MADR 4.0.0 format. ADRs are numbered sequentially (never reused or reordered) but grouped by domain below for discoverability.
Governance & Process
Decisions about how the project operates: format standards, licensing, versioning, contribution workflows, and documentation tooling.
| ADR | Title | Status |
|---|---|---|
| ADR-0001 | Use MADR 4.0.0 for Architecture Decision Records | Accepted |
| ADR-0003 | Dual Licensing – Apache 2.0 for Code, CC-BY-4.0 for Spec | Accepted |
| ADR-0004 | RFC-Based Governance with Steering Committee Transition | Accepted |
| ADR-0005 | Semantic Versioning for Spec and Implementations | Accepted |
| ADR-0030 | Structured Data Contributions via Issue Templates | Accepted |
| ADR-0031 | RFC Changes Require Reference Implementation | Superseded by ADR-0045 |
| ADR-0033 | mdbook with Mermaid for Documentation | Accepted |
| ADR-0045 | Specification-Only Repository | Accepted |
Core Data Model
The foundational entity model: game entity design, relationships, taxonomy, player count polls, playtime modeling, editions, and classification criteria.
| ADR | Title | Status |
|---|---|---|
| ADR-0006 | Unified Game Entity with Type Discriminator | Accepted |
| ADR-0007 | Combinatorial Expansion Property Model | Accepted |
| ADR-0008 | UUIDv7 Primary Keys with URL Slugs and BGG Cross-References | Accepted |
| ADR-0009 | Controlled Vocabulary for Taxonomy | Accepted |
| ADR-0010 | Structured Per-Player-Count Polling Data | Accepted |
| ADR-0011 | Typed Game Relationships with JSONB Metadata | Accepted |
| ADR-0014 | Dual Playtime Model – Publisher-Stated and Community-Reported | Accepted |
| ADR-0034 | Experience-Bucketed Playtime Adjustment | Proposed |
| ADR-0035 | Edition-Level Property Deltas | Accepted |
| ADR-0037 | Formal Entity Type Classification Criteria | Proposed |
| ADR-0043 | Player Count Sentiment Model Improvements | Proposed |
| ADR-0044 | Player Entity and Collection Data | Proposed |
| ADR-0045 | Specification-Only Repository | Accepted |
API Design
Protocol, pagination, filtering, error handling, resource embedding, hypermedia, caching, and bulk export.
| ADR | Title | Status |
|---|---|---|
| ADR-0002 | Use REST with OpenAPI 3.2 as the API Protocol | Accepted |
| ADR-0012 | Keyset (Cursor-Based) Pagination | Accepted |
| ADR-0013 | Compound Multi-Dimensional Filtering as Core Feature | Accepted |
| ADR-0015 | RFC 9457 Problem Details for Error Responses | Accepted |
| ADR-0016 | API Key Authentication with Tiered Rate Limits | Accepted |
| ADR-0017 | Selective Resource Embedding via ?include Parameter | Accepted |
| ADR-0018 | HAL-Style Hypermedia Links for Discoverability | Accepted |
| ADR-0019 | Bulk Data Export Endpoints | Accepted |
| ADR-0028 | Cache-Control Headers and ETags | Accepted |
Infrastructure & Implementation Guidance
Cloud-native design, deployment, observability, search, database migrations, and legacy system migration. These ADRs document recommended patterns for operators building conforming servers – they are guidance, not requirements of the standard. ADR-0025 (reference server) and ADR-0026 (SDK generation) are superseded by ADR-0045.
| ADR | Title | Status |
|---|---|---|
| ADR-0020 | Twelve-Factor Application Design | Accepted |
| ADR-0021 | Distroless Container Images | Accepted |
| ADR-0022 | Kubernetes-Native Deployment with Docker Compose Fallback | Accepted |
| ADR-0023 | OpenTelemetry for Unified Observability | Accepted |
| ADR-0024 | Immutable Infrastructure with Blue-Green Deployment | Accepted |
| ADR-0025 | Rust with Axum and SQLx for the Reference Server | Superseded by ADR-0045 |
| ADR-0026 | OpenAPI Generator for SDK Generation | Superseded by ADR-0045 |
| ADR-0027 | PostgreSQL Full-Text Search | Accepted |
| ADR-0029 | Versioned Plain SQL Migration Files | Accepted |
| ADR-0032 | Strangler Fig Pattern for BGG Legacy Migration | Accepted |
Data Model Extensions
Extensions to the core model for BGG parity, publisher/designer utility, and analytical capabilities.
| ADR | Title | Status |
|---|---|---|
| ADR-0036 | Time-Series Snapshots and Trend Analysis | Proposed |
| ADR-0038 | Alternate Names and Localization Support | Proposed |
| ADR-0039 | Extended Game Credits with Role Taxonomy | Proposed |
| ADR-0040 | Edition Product and Physical Metadata | Proposed |
| ADR-0041 | Community Signals and Aggregate Statistics | Proposed |
| ADR-0042 | Game Awards and Recognition | Proposed |
Chronological Index
All ADRs in sequential order for reference. Numbers are append-only and never reused.
| ADR | Title | Status |
|---|---|---|
| ADR-0001 | Use MADR 4.0.0 for Architecture Decision Records | Accepted |
| ADR-0002 | Use REST with OpenAPI 3.2 as the API Protocol | Accepted |
| ADR-0003 | Dual Licensing – Apache 2.0 for Code, CC-BY-4.0 for Spec | Accepted |
| ADR-0004 | RFC-Based Governance with Steering Committee Transition | Accepted |
| ADR-0005 | Semantic Versioning for Spec and Implementations | Accepted |
| ADR-0006 | Unified Game Entity with Type Discriminator | Accepted |
| ADR-0007 | Combinatorial Expansion Property Model | Accepted |
| ADR-0008 | UUIDv7 Primary Keys with URL Slugs and BGG Cross-References | Accepted |
| ADR-0009 | Controlled Vocabulary for Taxonomy | Accepted |
| ADR-0010 | Structured Per-Player-Count Polling Data | Accepted |
| ADR-0011 | Typed Game Relationships with JSONB Metadata | Accepted |
| ADR-0012 | Keyset (Cursor-Based) Pagination | Accepted |
| ADR-0013 | Compound Multi-Dimensional Filtering as Core Feature | Accepted |
| ADR-0014 | Dual Playtime Model – Publisher-Stated and Community-Reported | Accepted |
| ADR-0015 | RFC 9457 Problem Details for Error Responses | Accepted |
| ADR-0016 | API Key Authentication with Tiered Rate Limits | Accepted |
| ADR-0017 | Selective Resource Embedding via ?include Parameter | Accepted |
| ADR-0018 | HAL-Style Hypermedia Links for Discoverability | Accepted |
| ADR-0019 | Bulk Data Export Endpoints | Accepted |
| ADR-0020 | Twelve-Factor Application Design | Accepted |
| ADR-0021 | Distroless Container Images | Accepted |
| ADR-0022 | Kubernetes-Native Deployment with Docker Compose Fallback | Accepted |
| ADR-0023 | OpenTelemetry for Unified Observability | Accepted |
| ADR-0024 | Immutable Infrastructure with Blue-Green Deployment | Accepted |
| ADR-0025 | Rust with Axum and SQLx for the Reference Server | Superseded by ADR-0045 |
| ADR-0026 | OpenAPI Generator for SDK Generation | Superseded by ADR-0045 |
| ADR-0027 | PostgreSQL Full-Text Search | Accepted |
| ADR-0028 | Cache-Control Headers and ETags | Accepted |
| ADR-0029 | Versioned Plain SQL Migration Files | Accepted |
| ADR-0030 | Structured Data Contributions via Issue Templates | Accepted |
| ADR-0031 | RFC Changes Require Reference Implementation | Superseded by ADR-0045 |
| ADR-0032 | Strangler Fig Pattern for BGG Legacy Migration | Accepted |
| ADR-0033 | mdbook with Mermaid for Documentation | Accepted |
| ADR-0034 | Experience-Bucketed Playtime Adjustment | Proposed |
| ADR-0035 | Edition-Level Property Deltas | Accepted |
| ADR-0036 | Time-Series Snapshots and Trend Analysis | Proposed |
| ADR-0037 | Formal Entity Type Classification Criteria | Proposed |
| ADR-0038 | Alternate Names and Localization Support | Proposed |
| ADR-0039 | Extended Game Credits with Role Taxonomy | Proposed |
| ADR-0040 | Edition Product and Physical Metadata | Proposed |
| ADR-0041 | Community Signals and Aggregate Statistics | Proposed |
| ADR-0042 | Game Awards and Recognition | Proposed |
| ADR-0043 | Player Count Sentiment Model Improvements | Proposed |
| ADR-0044 | Player Entity and Collection Data | Proposed |
| ADR-0045 | Specification-Only Repository | Accepted |
status: accepted date: 2026-03-12
ADR-0001: Use MADR 4.0.0 for Architecture Decision Records
Context and Problem Statement
The OpenTabletop project needs a consistent way to document architectural decisions so that contributors, maintainers, and future developers can understand the reasoning behind key choices. Without a structured format, decision rationale gets lost in chat logs, issue threads, and tribal knowledge. We need a format that is readable, version-controllable, and easy to author.
Decision Drivers
- Decisions must be easy to write and review in pull requests
- The format should be widely recognized in the open-source community
- Tooling support (linters, templates, generators) is desirable
- Records should be stored alongside the code in version control
Considered Options
- MADR (Markdown Any Decision Records) 4.0.0
- Nygard format (original ADR format by Michael Nygard)
- Custom format tailored to project needs
Decision Outcome
Chosen option: “MADR 4.0.0”, because it provides a structured yet flexible template that balances thoroughness with ease of authoring. MADR is widely adopted across open-source projects, has strong community support, and includes sections for decision drivers and consequences that the Nygard format lacks. The structured YAML frontmatter enables tooling integration for status tracking and filtering.
Consequences
- Good, because all decisions follow a consistent, reviewable format
- Good, because MADR’s widespread adoption means contributors are likely already familiar with it
- Good, because tooling like adr-tools and log4brains can parse and index MADR records
- Bad, because the template has more sections than the minimal Nygard format, which may feel heavyweight for trivial decisions
status: accepted date: 2026-03-12
ADR-0003: Dual Licensing – Apache 2.0 for Code, CC-BY-4.0 for Spec
Context and Problem Statement
The OpenTabletop project consists of two distinct artifacts: the API specification (OpenAPI document, ADRs, documentation) and the reference implementation code (server, SDKs, tooling). These artifacts have different usage patterns and different intellectual property concerns. The specification is meant to be adopted and implemented by anyone, while the code benefits from patent protections. We need a licensing strategy that encourages adoption while protecting contributors.
Decision Drivers
- The specification must be freely implementable by anyone without patent concerns
- Code contributions need patent grant protection for contributors and users
- The model should be familiar to the open-source community and easy to understand
- Attribution should be required for the specification to credit the community
Considered Options
- MIT License for everything
- Apache License 2.0 for everything
- Dual license: Apache 2.0 for code, CC-BY-4.0 for specification and documentation
Decision Outcome
Chosen option: “Dual license – Apache 2.0 for code, CC-BY-4.0 for spec”, because this mirrors the approach used by the OpenAPI Initiative and provides the best fit for each artifact type. Apache 2.0 includes an explicit patent grant that protects contributors and users of the code, which MIT lacks. CC-BY-4.0 is the standard license for creative and specification works, requiring attribution while allowing free use and adaptation. MIT was rejected because it lacks patent protection, and using Apache 2.0 alone would be awkward for non-code specification documents.
Consequences
- Good, because Apache 2.0’s patent grant protects all code contributors and downstream users
- Good, because CC-BY-4.0 allows anyone to implement the spec while requiring attribution to the OpenTabletop project
- Good, because this dual model is well-understood, following the OpenAPI Initiative precedent
- Bad, because contributors must understand two licenses and which applies to their contribution
- Bad, because dual licensing adds complexity to the CONTRIBUTING guide and requires clear file-level boundaries
status: accepted date: 2026-03-12
ADR-0004: RFC-Based Governance with Steering Committee Transition
Context and Problem Statement
As an open specification project, OpenTabletop needs a governance model that balances rapid early development with long-term community ownership. Specification changes have far-reaching consequences for all implementations, so they require more deliberation than typical code changes. We need a process that is lightweight enough to not stall progress but structured enough to prevent unilateral breaking changes.
Decision Drivers
- Early-stage projects need fast decision-making to build momentum
- Specification changes affect all downstream implementations and must be deliberated
- The governance model should scale as the contributor base grows
- Transparency and inclusivity are essential for community trust
Considered Options
- BDFL (Benevolent Dictator for Life) model permanently
- Do-ocracy where whoever does the work decides
- RFC process with transition from BDFL to elected steering committee
Decision Outcome
Chosen option: “RFC process with BDFL-to-steering-committee transition”, because it provides the right governance at each stage of project maturity. Initially, the project founder operates as BDFL to make rapid decisions and set direction. All specification changes go through an RFC process: a written proposal is submitted, discussed in a public thread for a minimum review period, and then decided. Once the project reaches 10 or more active contributors, governance transitions to an elected steering committee of 3-5 members with rotating terms. The RFC process remains constant throughout – only the decision-making body changes.
Consequences
- Good, because the RFC process ensures all spec changes are documented and deliberated before adoption
- Good, because the BDFL phase allows fast bootstrapping without governance overhead
- Good, because the transition to elected steering committee ensures long-term community ownership
- Bad, because the RFC process adds latency to specification changes compared to direct commits
- Bad, because the transition threshold (10+ contributors) is somewhat arbitrary and may need adjustment
status: accepted date: 2026-03-12
ADR-0005: Semantic Versioning for Spec and Implementations
Context and Problem Statement
The OpenTabletop project has multiple versioned artifacts: the API specification, the reference server, and generated SDKs. Consumers need to understand at a glance whether an update is safe to adopt or contains breaking changes. We need a versioning scheme that communicates compatibility clearly and works across the specification and its implementations.
Decision Drivers
- Breaking changes to the API spec must be immediately obvious to implementers
- SDKs and the reference server version independently but must clearly state which spec version they support
- The versioning scheme must be widely understood and tooling-friendly
- Pre-release and build metadata should be supported for development workflows
Considered Options
- CalVer (calendar versioning, e.g., 2026.03)
- SemVer (semantic versioning, MAJOR.MINOR.PATCH)
- Custom versioning scheme
Decision Outcome
Chosen option: “SemVer”, because it is the most widely understood versioning scheme and directly communicates the impact of each release. For the API specification: a MAJOR bump means breaking changes to the API contract, MINOR means additive non-breaking changes (new endpoints, new optional fields), and PATCH means documentation fixes or clarifications with no behavioral change. SDKs and the reference server version independently using their own SemVer numbers but declare their compatible spec version in metadata (e.g., spec-compatibility: 1.2.x). CalVer was rejected because it does not communicate compatibility information.
Consequences
- Good, because consumers can immediately assess upgrade risk from the version number
- Good, because SemVer is universally understood and supported by every package manager
- Good, because independent SDK versioning allows bug fixes without waiting for spec releases
- Bad, because maintaining the spec-compatibility mapping across multiple SDKs requires discipline
- Bad, because SemVer’s “breaking change = major bump” can lead to high major version numbers if the spec evolves rapidly in early stages
status: accepted date: 2026-03-12
ADR-0030: Structured Data Contributions via Issue Templates
Context and Problem Statement
The OpenTabletop project’s data quality depends on community contributions – game corrections, new game submissions, and data imports from external sources like BoardGameGeek. We need a contribution workflow that structures incoming data for easy review and verification while being accessible to non-technical contributors who may not be comfortable with pull requests.
Decision Drivers
- Data contributions must be structured enough to validate and import programmatically
- Non-technical community members should be able to contribute without Git expertise
- Every contribution must be reviewed and verified by a maintainer before entering the dataset
- Bulk imports from external sources (BGG) need admin-level tooling with audit trails
Considered Options
- Wiki-style open editing with revision history
- Pull request workflow requiring contributors to edit data files directly
- GitHub issue templates for structured data intake with maintainer verification
Decision Outcome
Chosen option: “GitHub issue templates for structured data intake”, because issue templates provide structured forms that guide contributors through the required fields while being accessible to anyone with a GitHub account. Templates are provided for: new game submission, game data correction, new taxonomy term proposal, and bulk import request. Each template collects the required fields in a structured format that maintainers can validate and import. Bulk imports from external sources like BGG use separate admin tooling with audit logging. Wiki-style editing was rejected because unrestricted editing without review leads to data quality degradation. Direct PR workflows were rejected because editing JSON/YAML data files requires technical skills that exclude most community contributors.
Consequences
- Good, because structured issue templates guide contributors to provide all required information
- Good, because the contribution workflow is accessible to non-technical community members
- Good, because maintainer review ensures data quality before any contribution enters the dataset
- Bad, because the maintainer review step creates a bottleneck that may slow contribution processing
- Bad, because issue templates are less flexible than direct data editing for complex corrections
status: superseded by ADR-0045 date: 2026-03-12
ADR-0031: RFC Changes Require Reference Implementation
Context and Problem Statement
Specification changes that look reasonable on paper can prove impractical or ambiguous when actually implemented. Spec drift – where the specification diverges from what implementations can realistically support – is a common failure mode of API standards projects. We need a process that grounds every specification change in real, working code.
Decision Drivers
- Specification changes must be proven implementable before adoption
- The reference implementation must always be in sync with the current spec version
- The process must prevent spec drift without requiring every implementation to update simultaneously
- SDK updates should be tracked alongside specification changes
Considered Options
- Spec-only changes are allowed – implementations catch up later
- Every spec change requires at least one reference implementation PR
- Every spec change requires all implementations (server + all SDKs) to be updated simultaneously
Decision Outcome
Chosen option: “Every spec change requires one reference implementation PR plus SDK update tracking”, because it ensures every specification change is proven implementable without creating an unsustainable requirement for simultaneous multi-language updates. The process is: (1) submit an RFC for the spec change (per ADR-0004), (2) during the RFC review period, prepare a reference server implementation PR that demonstrates the change, (3) the spec RFC and reference implementation PR are merged together, (4) SDK update issues are automatically created for each SDK to track the update. This ensures the spec is always grounded in working code while giving SDK maintainers a reasonable window to catch up. Spec-only changes were rejected because they inevitably lead to spec drift. Requiring all implementations was rejected because it creates an unrealistic bottleneck that would slow spec evolution to a crawl.
Consequences
- Good, because every specification change is proven implementable by working code before adoption
- Good, because the reference server always reflects the current specification
- Good, because SDK update tracking ensures language bindings eventually catch up without blocking spec evolution
- Bad, because requiring a reference implementation PR adds effort and time to every spec change
- Bad, because the reference implementation is in Rust, which means the spec is only proven implementable in one language
status: accepted date: 2026-03-12
ADR-0033: mdbook with Mermaid for Documentation
Context and Problem Statement
The OpenTabletop project needs a documentation site for the specification, ADRs, architecture guides, and contributor documentation. The documentation tool should integrate naturally with the project’s Rust ecosystem, support architecture diagrams, and deploy easily to GitHub Pages via CI. Markdown-based authoring is essential so that documentation changes go through the same PR review process as code.
Decision Drivers
- Documentation must be authored in Markdown and version-controlled alongside the code
- Architecture diagrams should be defined as code (not binary images) for reviewability
- The documentation tool should align with the Rust ecosystem used by the reference server
- Deployment to GitHub Pages via CI should be straightforward
Considered Options
- Docusaurus (React-based, JavaScript ecosystem)
- mdbook (Rust-based, Markdown-native)
- MkDocs with Material theme (Python-based)
Decision Outcome
Chosen option: “mdbook with mermaid preprocessor”, because it is the standard documentation tool in the Rust ecosystem (used by the Rust Book, Tokio, and many other Rust projects), producing fast, clean, static sites from Markdown. The mdbook-mermaid preprocessor enables architecture diagrams, entity relationship diagrams, sequence diagrams, and flowcharts to be written as Mermaid code blocks directly in Markdown – making them reviewable in PRs and diffable in version control. Deployment is a single mdbook build command, producing static HTML that is deployed to GitHub Pages via a CI workflow. Docusaurus was rejected because it requires a Node.js toolchain that is not otherwise used in the project. MkDocs was rejected because it requires a Python toolchain; while functional, mdbook better aligns with the Rust-first ecosystem of the reference server.
Consequences
- Good, because mdbook aligns with the Rust ecosystem, requiring no additional toolchains
- Good, because Mermaid diagrams are code-reviewable Markdown, not binary images
- Good, because static HTML output deploys trivially to GitHub Pages with no server-side runtime
- Bad, because mdbook has fewer themes and plugins than Docusaurus or MkDocs Material
- Bad, because Mermaid diagrams render client-side, which may be slow for very complex diagrams
status: accepted date: 2026-03-17 supersedes:
- ADR-0025
- ADR-0026
- ADR-0031
ADR-0045: Specification-Only Repository
Context and Problem Statement
The OpenTabletop repository was structured as if it would ship a reference server implementation (Rust/Axum/SQLx), generated client SDKs (Rust, Python, JavaScript), and container images. In practice, these directories contain placeholder stubs – 34 lines of SDK code and 95 lines of server TODOs. The presence of implementation artifacts creates a misleading impression that the project is building a product rather than defining a standard. It also creates maintenance burden (dependabot PRs for unused dependencies, CI workflows building empty binaries) and confuses the contribution model (ADR-0031 required every spec change to include a reference implementation update).
OpenTabletop’s core value is as a standard and commons – an OpenAPI specification, controlled vocabularies, sample data, and documentation that anyone can use to build their own conforming implementation. The repository should reflect this identity.
Decision Drivers
- The project’s primary deliverables are schemas, vocabularies, and documentation – not executable code
- Placeholder implementation directories create false expectations and attract dependency churn (10 dependabot PRs for unused packages)
- Requiring reference implementation updates with every spec change (ADR-0031) blocks spec-only contributors
- Implementation guidance (twelve-factor, observability, container patterns) remains valuable as recommendations for implementers – it should be retained as documentation, not deleted
Considered Options
- Keep the current structure – maintain placeholder sdks/ and reference/ directories as scaffolding for future implementation
- Specification-only repository – remove implementation artifacts, retain implementation ADRs as guidance, add sample data
- Separate repositories – move implementation to a separate repo (e.g., opentabletop-server) while keeping spec here
Decision Outcome
Chosen option: “Specification-only repository”, because the project’s identity is a standard, not a product. Implementation code belongs in adopters’ repositories, not in the specification repository. This aligns with how other successful API standards operate (OpenAPI itself, JSON Schema, CloudEvents).
What This Repository Provides
- OpenAPI 3.1 specification (
spec/) – the canonical API contract - Controlled vocabularies (
data/taxonomy/) – curated mechanics, categories, and themes - BGG bridge mappings (
data/mappings/) – migration path from BoardGameGeek - Sample data (
data/samples/) – demonstration records conforming to the schemas - Documentation (
docs/) – ADRs, pillar documentation, implementer guides - Tooling (
tools/,scripts/) – taxonomy viewer, spec bundling, ADR validation
What This Repository Does Not Provide
- Server implementations (any language/framework)
- Client SDKs (generate from the OpenAPI spec using standard tooling)
- Container images (build from your own implementation)
- Hosted API instances
Superseded Decisions
- ADR-0025 (Rust with Axum and SQLx for the Reference Server) – The technology recommendation remains valid guidance for implementers but is no longer a project commitment. The
reference/directory is removed. - ADR-0026 (OpenAPI Generator for SDK Generation) – Implementers are encouraged to generate SDKs from the spec, but the project no longer ships or maintains generated SDKs. The
sdks/directory is removed. - ADR-0031 (RFC Changes Require Reference Implementation) – Spec changes no longer require a reference implementation update. The contribution workflow is: RFC discussion, community review, steering committee vote, PR with spec change and updated documentation.
Implementation ADRs Retained as Guidance
ADRs 0020-0024, 0027, 0029, and 0032 document recommended patterns for building conforming servers (twelve-factor design, container images, observability, database design, migration strategies). These are retained as implementer guidance in the ADR index, clearly labeled as recommendations rather than requirements of the standard.
Consequences
- Good, because the repository accurately reflects its purpose as a specification and commons
- Good, because contributors can work on the spec without needing Rust/Python/JavaScript toolchains
- Good, because dependabot and CI no longer churn on unused implementation dependencies
- Good, because the contribution model is simpler – spec changes need spec + docs, not code in three languages
- Neutral, because implementers lose a reference server to test against – but the existing reference server was non-functional (all TODOs)
- Bad, because there is no longer a canonical “this is how you build it” implementation – implementers must rely on the spec, documentation, and sample data
- Bad, because future protobuf/gRPC definitions are not yet included – this is noted as a future consideration
Future Considerations
- Protobuf definitions –
.protofiles alongside the OpenAPI spec would enable gRPC-based implementations. These can be added when there is concrete demand. - Conformance test suite – A language-agnostic test suite that validates any implementation against the spec would partially replace the reference server’s role as a conformance target.
- Community implementations – The README and documentation should link to known community implementations as they emerge.
status: accepted date: 2026-03-12
ADR-0006: Unified Game Entity with Type Discriminator
Context and Problem Statement
Board games come in many forms: base games, expansions, standalone expansions, promos, accessories, and fan expansions. These types share the vast majority of their attributes (name, description, player count, playtime, weight) but differ in their relationships and a few type-specific fields. We need a data model that handles all game types without unnecessary complexity or query overhead.
Decision Drivers
- Queries across all game types (e.g., “all games by designer X”) must be simple and fast
- The model must accommodate type-specific attributes without excessive nullability
- Expansion-aware filtering requires knowing a game’s type at query time
- The schema should be easy to understand for API consumers
Considered Options
- Separate tables per game type (games, expansions, promos, etc.)
- Single unified table with a type discriminator column
- Polymorphic inheritance with a base table and type-specific extension tables
Decision Outcome
Chosen option: “Single unified table with type discriminator”, because it produces the simplest queries and avoids JOINs for the most common operations. The game_type column uses an enum with values: base_game, expansion, standalone_expansion, promo, accessory, and fan_expansion. Type-specific attributes that only apply to certain types (e.g., requires_base_game for expansions) are nullable columns. Separate tables were rejected because cross-type queries become expensive UNIONs. Polymorphic inheritance was rejected because the shared attribute set is so large that extension tables would have very few columns, making the complexity unjustified.
Consequences
- Good, because all game types are queryable from a single table with simple WHERE clauses
- Good, because the discriminator column enables efficient type-filtered indexes
- Good, because the API surface is uniform – one /games endpoint serves all types
- Bad, because some columns are nullable for types where they don’t apply (e.g., base games don’t have
requires_base_game) - Bad, because adding a new game type requires careful review of which columns apply
status: accepted date: 2026-03-12
ADR-0007: Combinatorial Expansion Property Model
Context and Problem Statement
When expansions are combined with a base game, the resulting gameplay properties (player count, playtime, weight, mechanics) may differ from the simple sum of base and expansion values. For example, combining two specific expansions might unlock a player count or mechanic that neither provides individually. We need a model that captures these emergent combination effects while keeping the common case (simple additive deltas) easy to manage.
Decision Drivers
- Most expansion effects are simple additive deltas (e.g., “+2 max players”) and should be easy to express
- Some expansion combinations produce emergent properties that cannot be derived by summing deltas
- API consumers need to know whether a property came from an explicit combination record or was computed
- The model must not require a full matrix of all possible combinations (combinatorial explosion)
Considered Options
- Simple additive deltas only – each expansion declares its delta from the base game
- ExpansionCombination entity for explicit combo effects with additive delta fallback
- Full combination matrix precomputing all possible expansion sets
Decision Outcome
Chosen option: “ExpansionCombination entity with additive delta fallback”, because it handles the common case simply while supporting emergent combination effects where they exist. The resolution follows a three-tier hierarchy: (1) if an explicit ExpansionCombination record exists for the requested set of expansions, use it; (2) otherwise, sum the individual expansion deltas with the base game properties; (3) if no delta information exists, return base game properties only. Every response includes a combination_source flag indicating which tier produced the values (explicit_combination, delta_sum, or base_only). The full matrix approach was rejected because the number of possible expansion combinations grows exponentially and most combinations have no emergent effects.
Consequences
- Good, because the common case (additive deltas) requires no extra data entry beyond the expansion’s own properties
- Good, because emergent combination effects can be explicitly recorded when discovered
- Good, because the
combination_sourceflag gives consumers transparency into the data quality - Bad, because explicit combination records must be manually created and maintained by contributors
- Bad, because three-tier resolution adds complexity to the query engine
status: accepted date: 2026-03-12
ADR-0008: UUIDv7 Primary Keys with URL Slugs and BGG Cross-References
Context and Problem Statement
The OpenTabletop project needs a primary key strategy that works across distributed systems (multiple implementations, offline-capable clients, data imports) while also providing human-friendly URLs. Additionally, since much of the initial data will be migrated from BoardGameGeek, we need a way to maintain cross-references to BGG identifiers for backward compatibility and data reconciliation.
Decision Drivers
- Primary keys must be globally unique without central coordination for distributed implementations
- URLs should be human-readable and shareable (e.g., /games/catan rather than /games/01912f4c-…)
- Time-ordered keys improve database index performance (B-tree locality)
- Cross-references to existing BGG IDs are essential for migration and interoperability
Considered Options
- Auto-incrementing integers
- UUIDv4 (random) primary keys
- UUIDv7 (time-ordered) primary keys with URL slugs and BGG cross-references
Decision Outcome
Chosen option: “UUIDv7 with slugs and BGG cross-references”, because UUIDv7 provides globally unique, time-ordered identifiers that can be generated by any implementation without coordination while maintaining excellent B-tree index performance. URL slugs are stored as a separate unique column, enabling human-friendly URLs like /games/catan while keeping the UUID as the stable internal identifier. BGG IDs are stored in a dedicated cross-reference table (external_references) rather than as a column on the games table, allowing multiple external ID systems. Auto-increment was rejected because it requires central coordination. UUIDv4 was rejected because its random ordering causes index fragmentation.
Consequences
- Good, because UUIDv7’s time-ordering provides natural chronological sorting and excellent index locality
- Good, because slugs enable memorable, shareable URLs without exposing internal IDs
- Good, because the cross-reference table supports BGG IDs and any future external system references
- Bad, because UUIDs are larger than integers (16 bytes vs 4-8 bytes), increasing storage and join costs
- Bad, because slug uniqueness must be enforced and slug generation requires collision handling (e.g., catan, catan-2, catan-3)
status: accepted date: 2026-03-12
ADR-0009: Controlled Vocabulary for Taxonomy (Mechanics, Categories, Themes)
Context and Problem Statement
Board games are classified by mechanics (deck building, worker placement), categories (strategy, party), and themes (fantasy, sci-fi, historical). These taxonomies are critical for filtering and discovery, but inconsistent or uncontrolled tagging leads to fragmentation – where the same concept has multiple spellings, synonyms, or granularity levels. We need a taxonomy strategy that enables precise, consistent filtering.
Decision Drivers
- Filtering by mechanic or category must produce consistent, reliable results
- Tag proliferation (e.g., “deck-building” vs “deckbuilding” vs “deck construction”) degrades search quality
- New taxonomy terms are needed over time as game design evolves
- The vocabulary should be authoritative enough for data interchange between implementations
Considered Options
- Free tagging – anyone can create any tag
- Controlled vocabulary – curated list of approved terms with an RFC process for additions
- Hybrid – controlled vocabulary with user-submitted free tags that are periodically reviewed
Decision Outcome
Chosen option: “Controlled vocabulary with RFC process for new terms”, because it prevents the tag fragmentation that plagues free-tagging systems while providing a clear, community-driven path for vocabulary evolution. Each taxonomy term has a canonical slug, display name, and optional description. New terms are proposed through the RFC process (see ADR-0004), discussed publicly, and added only after approval. Synonyms and common misspellings are stored as aliases that map to the canonical term. Free tagging was rejected because it inevitably leads to inconsistent data. The hybrid approach was rejected because the review overhead of triaging free tags is unsustainable.
Consequences
- Good, because every mechanic, category, and theme has exactly one canonical representation
- Good, because synonym aliases improve data import quality by mapping variant names to canonical terms
- Good, because the RFC process ensures new terms are well-defined and distinct from existing ones
- Bad, because the approval process adds friction when a genuinely new mechanic emerges in game design
- Bad, because the initial vocabulary curation requires significant upfront effort
status: accepted date: 2026-03-12
ADR-0010: Structured Per-Player-Count Polling Data
Context and Problem Statement
Board games have a stated player count range (e.g., 2-5 players), but the quality of the experience varies dramatically by player count. A game that “supports” 2 players may be mediocre at 2 but outstanding at 4. Community polling data that captures per-player-count sentiment (best, recommended, not recommended) is essential for meaningful discovery. We need a data model that captures this nuance beyond simple min/max ranges.
Decision Drivers
- “Best at 3 players” is fundamentally different from “supports 3 players” and the API must express this
- Poll data should support rich queries like “show me games that are best at exactly 2 players”
- The model must accommodate both publisher-stated ranges and community sentiment
- Data should be compatible with existing BGG poll data for migration purposes
Considered Options
- Simple min/max player count range
- Weighted range with a single “sweet spot” indicator
- Per-player-count polls with best/recommended/not-recommended votes
Decision Outcome
Chosen option: “Per-player-count polls with best/recommended/not-recommended votes”, because it captures the full distribution of community sentiment at each player count and enables the richest possible filtering. Each game has a player_count_polls array where each entry contains a player count value and vote tallies for best, recommended, and not_recommended. This enables queries like “games that are best at exactly 3 players” (where best votes dominate at count=3) or “games to avoid at 2 players” (where not_recommended dominates at count=2). The simple range was rejected because it loses all quality-of-experience information. The weighted range was rejected because a single sweet spot cannot express bimodal distributions (e.g., great at 2 or 5, mediocre at 3-4).
Consequences
- Good, because the full vote distribution enables precise filtering that no other board game API offers
- Good, because the data model is directly compatible with BGG’s existing poll data, simplifying migration
- Good, because the poll structure naturally extends to other poll types (e.g., language dependence, age suitability)
- Bad, because per-player-count poll data is significantly more storage than a simple min/max range
- Bad, because games with few votes may have unreliable poll distributions, requiring a minimum vote threshold for filtering
Future Considerations
ADR-0043 adopts a numeric per-count rating model (1-5 scale) as the native replacement for the three-tier system documented here. The core decision of this ADR – structured per-player-count sentiment data rather than simple min/max ranges – carries forward. The change is in how sentiment is collected: independent numeric ratings per count rather than Best/Recommended/Not Recommended buckets. The BGG three-tier data is preserved as PlayerCountPollLegacy for migration compatibility.
status: accepted date: 2026-03-12
ADR-0011: Typed Game Relationships with JSONB Metadata
Context and Problem Statement
Board games have rich relationships with each other: a game can be expanded by expansions, reimplemented as a new edition, contained within a compilation, required as a dependency, recommended as a companion, or designed to integrate mechanically with another game. These relationships are typed and directional, and some carry additional metadata (e.g., which edition a reimplementation replaces). We need a relationship model that captures this variety without over-engineering.
Decision Drivers
- Relationship types are diverse and directional (A expands B is different from B expands A)
- Some relationship types carry metadata (e.g., integration instructions, edition history)
- Queries like “all expansions for game X” and “all games that reimplement game Y” must be efficient
- The model should be extensible to new relationship types without schema changes
Considered Options
- Simple parent_id foreign key on the games table
- Dedicated GameRelationship table with typed edges and JSONB metadata
- Full graph database (Neo4j or similar)
Decision Outcome
Chosen option: “GameRelationship table with typed edges and JSONB metadata”, because it captures the full variety of game relationships in a relational model without requiring graph database infrastructure. The table has columns: source_game_id, target_game_id, relationship_type (enum: expands, reimplements, contains, requires, recommends, integrates_with), and a metadata JSONB column for type-specific attributes. Indexes on (source_game_id, relationship_type) and (target_game_id, relationship_type) enable efficient lookups in both directions. The parent_id approach was rejected because it can only model one relationship type. A graph database was rejected because it adds significant operational complexity for a relationship model that is well-served by indexed relational queries.
Consequences
- Good, because all relationship types are modeled uniformly with a single table and query pattern
- Good, because JSONB metadata allows type-specific attributes without schema proliferation
- Good, because new relationship types can be added to the enum without structural changes
- Bad, because JSONB metadata is less strictly typed than dedicated columns, requiring application-level validation
- Bad, because bidirectional queries require checking both source and target columns (mitigated by dual indexes)
status: accepted date: 2026-03-12
ADR-0014: Dual Playtime Model – Publisher-Stated and Community-Reported
Context and Problem Statement
Board game play times as stated by publishers (printed on the box) are notoriously inaccurate – they often represent ideal conditions and experienced players. Community-reported play times from actual play logs are typically more reliable but are not always available, especially for new or niche games. The API needs to represent both sources of playtime data and make intelligent defaults for filtering.
Decision Drivers
- Publisher-stated playtimes are universally available but often inaccurate
- Community-reported playtimes are more accurate but require sufficient play log data
- Consumers should be able to distinguish between the two sources
- Filtering should default to the most accurate available data
Considered Options
- Publisher-stated playtime only
- Community-reported playtime only
- Both publisher-stated and community-reported, with smart defaulting
Decision Outcome
Chosen option: “Both publisher-stated and community-reported playtimes”, because each serves a different purpose and both have value. The game entity includes publisher_playtime_min and publisher_playtime_max (from the box) as well as community_playtime_min, community_playtime_max, and community_playtime_median (from aggregated play logs). When filtering by playtime, the API defaults to community-reported values when sufficient data exists (minimum vote threshold) and falls back to publisher-stated values otherwise. The response includes a playtime_source field indicating which source was used. Publisher-only was rejected because the data is too unreliable for accurate filtering. Community-only was rejected because new games would have no playtime data at all.
Consequences
- Good, because consumers get the most accurate playtime data available for each game
- Good, because the
playtime_sourcefield provides transparency about data provenance - Good, because publisher playtimes serve as a universal fallback ensuring every game has some playtime data
- Bad, because maintaining two parallel playtime datasets increases storage and complexity
- Bad, because the threshold for “sufficient community data” is a tunable parameter that affects filtering results
status: proposed date: 2026-03-13
ADR-0034: Experience-Bucketed Playtime Adjustment
Context and Problem Statement
Publisher-stated and community-reported play times (ADR-0014) implicitly assume experienced players. First-time players typically take 40-60% longer – box times are set by designers and playtesters who have played the game hundreds of times. When filtering by playtime for game night (“we have 90 minutes”), the system should account for whether the group knows the game or is learning it for the first time. No existing board game API or app models experience-adjusted time predictions.
Setup and teardown time is also systematically excluded from publisher estimates. A game like Spirit Island may claim 90-120 minutes, but a first-time group will spend 20 minutes on setup alone, then 150+ minutes playing, because every card requires reading, every decision tree is unfamiliar, and rules questions interrupt flow.
Decision Drivers
- First plays take ~50% longer than experienced plays; this is systematic, not random
- The existing dual playtime model (ADR-0014) does not distinguish experience levels
- Filtering by “time I actually have” requires knowing the group’s familiarity with the game
- Data must be community-contributed, following the Pillar 3 philosophy of raw distributions
- Must compose with existing 6-dimensional filtering (ADR-0013) without adding a new dimension
- Must work with effective mode and expansion combinations (ADR-0007)
- Different games have different experience curves – a party game has near-zero first-play penalty while a heavy euro may have a 2× penalty
Considered Options
- Full per-player-count × per-experience-level matrix – Store times for every (player_count, experience_level) cell
- Hardcoded multipliers per game – Store a single set of fixed multipliers
- Experience-level poll data with derived multipliers – Community-reported playtime bucketed by experience level, with multipliers derived from the raw data
Decision Outcome
Chosen option: “Experience-level poll data with derived multipliers,” because it follows the project’s established pattern of storing raw community distributions (Pillar 3, like PlayerCountPoll in ADR-0010) while deriving practical filtering values. Four experience levels are defined:
| Level | Description | Typical Multiplier |
|---|---|---|
first_play | Everyone is new to the game | ~1.5× |
learning | 1-3 prior plays, still referencing rules | ~1.25× |
experienced | 4+ plays, knows the rules well (baseline) | 1.0× |
expert | Optimized play, minimal downtime | ~0.85× |
Community play logs include a self-reported experience level. The system aggregates these into per-level median and percentile times. Multipliers are derived as ratios relative to the experienced baseline: multiplier[level] = median[level] / median[experienced].
Games without sufficient experience data fall back to global default multipliers derived from aggregate data across all games.
Consequences
- Good, because consumers can filter by actual available time for their group’s experience level
- Good, because raw poll data supports future statistical analysis (Pillar 3)
- Good, because the experience parameter is a modifier on existing playtime filtering, not a new filter dimension – it composes naturally with
playtime_sourceandeffective=true - Good, because game-specific multipliers capture the reality that different games have different experience curves
- Good, because global default multipliers ensure the feature works even for games without experience-specific data
- Bad, because play log contributions must now include an experience level field, adding friction to data entry
- Bad, because games with few play reports will have unreliable experience-level breakdowns (mitigated by
sufficient_dataflag and global fallbacks)
Rejected Options
The full matrix was rejected because it requires community data for every cell (player_count × experience_level), and most cells would have too few data points to be statistically meaningful. The per-player-count breakdown already exists independently in the community playtime statistics data.
Hardcoded multipliers were rejected because different games have fundamentally different experience curves: a party game like Codenames has almost no first-play penalty (the rules take 2 minutes to explain), while a heavy game like Through the Ages may have a 2× first-play penalty. Game-specific community data is essential for accuracy.
Implementation
New Schemas
ExperiencePlaytimePoll– Per-level community-reported playtime data (median, p10, p90, report count)ExperiencePlaytimeProfile– Aggregate profile with all four levels and derived multipliers
API Changes
- New endpoint:
GET /games/{id}/experience-playtime– returns the full experience profile - New query parameter:
playtime_experience– adjusts playtime filtering for a given experience level - New POST filter field:
playtime.experienceinSearchRequest - New include value:
experience_playtimefor embedding in game responses
Filter Composition
The experience parameter modifies Dimension 2 (Play Time) without creating a new dimension:
source selection → expansion resolution → experience adjustment → comparison
When playtime_experience=first_play and playtime_max=120:
- Select the playtime source (publisher or community)
- Resolve effective playtime if
effective=true(ADR-0007) - Apply the first_play multiplier to the game’s playtime
- Compare the adjusted value against 120 minutes
status: accepted date: 2026-03-13
ADR-0035: Edition-Level Property Deltas
Context and Problem Statement
Board game databases like BGG lump all printings of a game under a single record. Consider Samurai (BGG /boardgame/3/samurai): the original 1998 Hans im Gluck/Rio Grande printing and the 2015 Fantasy Flight Games reprint share one entry, yet they differ in component quality, graphic design, rules clarity, and potentially gameplay feel. The 2015 edition includes revised rules with clearer examples and upgraded components, resulting in a slightly different perceived weight.
The current GameEdition schema (see spec/schemas/GameEdition.yaml) captures only flat publishing metadata: publisher, year, language, and freeform notes. It cannot express structured property differences between printings. This means edition-specific weight, playtime, or player-count differences are invisible to the filtering system. A user searching for “games under weight 3.0” might miss a lighter reprint or include a heavier one, because the system only knows the canonical edition’s values.
The project already has a well-established delta pattern via PropertyModification (ADR-0007) for expansion effects. Editions need a similar but simpler model: one edition is active at a time, so there is no combinatorial explosion.
Decision Drivers
- Editions can differ in weight, playtime, and occasionally player count (e.g., a reprint adds a solo variant)
- Must compose with the existing expansion effective-properties pipeline (ADR-0007) and experience adjustment (ADR-0034)
- Only one edition is active at a time – no combinatorial explosion like expansions
- Should reuse the existing delta vocabulary (additive deltas, same field names) for consistency
- Must be backward compatible – games without edition data behave exactly as today
- Need both structured (filterable) deltas and human-readable change descriptions
Considered Options
- Freeform notes on GameEdition (status quo) – Keep the existing
notesfield for edition differences - Reuse PropertyModification with polymorphic source – Add an
edition_idfield toPropertyModificationand use the same schema for both expansion and edition deltas - New EditionDelta schema – A dedicated schema for edition-level property changes
Decision Outcome
Chosen option: “New EditionDelta schema,” because editions and expansions have fundamentally different resolution semantics. Expansions are combinatorial (any subset can be active simultaneously), while editions are mutually exclusive (exactly one is active). Overloading PropertyModification would conflate these two models and complicate both the API contract and the resolution logic.
Each game has one canonical edition (typically the original printing). All other editions may have an EditionDelta describing how their properties differ from the canonical. The canonical edition has no delta – it defines the baseline.
Edition selection is controlled via a query parameter (edition accepting a slug or UUID) that defaults to the canonical edition. The resolution pipeline becomes:
edition selection → edition delta → expansion resolution → experience adjustment → comparison
This slots in naturally before expansion resolution: first determine the base values for the selected edition, then apply expansion deltas on top of those values.
Consequences
- Good, because edition-specific property differences become filterable – a user can filter by the 2015 reprint’s actual weight
- Good, because the delta pattern is consistent with expansion
PropertyModification(same field names, same additive semantics) - Good, because one-at-a-time selection means no combinatorial explosion – the resolution cost is O(1) per edition
- Good, because it composes cleanly with the existing pipeline: edition deltas are applied before expansion resolution, which is applied before experience adjustment
- Good, because backward compatible – games with no edition data or queries without an
editionparameter behave identically to today - Good, because both structured deltas (for filtering) and human-readable descriptions (for display) are supported
- Bad, because it adds one more resolution step to the effective-properties pipeline
- Bad, because the canonical edition requires curation – someone must decide which printing is the baseline
- Bad, because edition delta data requires community contribution effort for each game with multiple printings
Rejected Options
Freeform notes were rejected because unstructured text cannot participate in filtering. A note saying “slightly heavier than the original” is invisible to a weight filter query.
Polymorphic PropertyModification was rejected because it would add branching logic to every consumer of that schema. Expansion deltas and edition deltas have different cardinality constraints (many-of-many vs one-of-many), different resolution algorithms, and different API semantics. A shared schema would save a few lines of YAML but create ongoing confusion in documentation, client code, and the resolution pipeline.
Implementation
New Schemas
EditionDelta– Structured property deltas for a specific edition relative to the canonical edition, including numeric deltas and human-readable change descriptions
API Changes
- New endpoint:
GET /games/{id}/editions– returns all editions of a game, optionally embedding delta data via?include=deltas - New query parameter:
editionon filtering endpoints – selects the active edition for property resolution - New include value:
edition_deltafor embedding in game responses
Filter Composition
The edition delta inserts at the beginning of the resolution pipeline:
edition selection → edition delta → expansion resolution → experience adjustment → comparison
When edition=samurai-2015-ffg and weight_max=3.0:
- Look up the edition delta for the 2015 FFG reprint
- Apply the weight delta to the canonical base weight
- Resolve expansion deltas if
effective=true(ADR-0007) - Apply experience adjustment if
playtime_experienceis set (ADR-0034) - Compare the final resolved values against filter criteria
status: proposed date: 2026-03-15
ADR-0037: Formal Entity Type Classification Criteria
Context and Problem Statement
The specification defines six game entity types via ADR-0006 (base_game, expansion, standalone_expansion, promo, accessory, fan_expansion) and an edition/version system via ADR-0035 (GameEdition + EditionDelta). However, there is no formal decision framework for classifying products into these types or for determining when a product should be a new entity versus a new edition of an existing one.
This mirrors real-world classification problems observed in BoardGameGeek, where:
- Promos and accessories are frequently conflated (a single promo card listed as an accessory, or vice versa)
- Standalone expansions are inconsistently classified as base games or expansions
- Remakes and reprints get separate entries when they should be editions of the same entity
- New entries are created for products that should be attached to a base game
- Fan content classification is ad hoc
Without formal criteria, contributors and data curators will reproduce these same inconsistencies. The project needs a decision tree and grey zone rules analogous to the taxonomy classification criteria (taxonomy-criteria.md), but for entity types and the entity-vs-edition boundary.
Decision Drivers
- Contributors and RFC reviewers need objective, repeatable criteria for type assignment
- The BGG data migration pipeline (ADR-0032) requires deterministic mapping rules for ambiguous BGG entries
- Six grey zone boundaries (promo/accessory, expansion/standalone_expansion, base_game/standalone_expansion, new entity/new edition, expansion/fan_expansion, big promo/small expansion) each need an explicit decision rule
- The edition system (ADR-0035) introduced a “new entity vs new edition” boundary with no documented resolution criteria
- Consistency with the taxonomy classification criteria pattern keeps documentation uniform
Considered Options
- Informal guidelines – Expand the existing type discriminator table in
games.mdwith longer descriptions and examples - Formal classification criteria as a dedicated documentation page with ADR – Create a full decision tree, grey zone rules, worked examples, BGG migration mapping, and RFC reviewer checklist
- Algorithmic classification – Define programmatic rules that automatically assign types during data import
Decision Outcome
Chosen option: “Formal classification criteria as a dedicated documentation page with ADR,” because repeatable, human-readable criteria serve both human contributors (RFC review, data correction) and automated import pipelines (BGG migration). The criteria document follows the established pattern of taxonomy-criteria.md: a mermaid decision tree flowchart, grey zone rules with worked examples, and an RFC reviewer checklist.
Informal guidelines were rejected because they lack the structure needed for consistent review decisions across contributors. Fully algorithmic classification was rejected because grey zone cases inherently require human judgment – the criteria guide that judgment rather than replace it.
Consequences
- Good, because contributors and reviewers have a single reference document for type decisions
- Good, because the BGG migration pipeline can reference the same criteria for deterministic import rules
- Good, because the “new entity vs new edition” boundary is formally documented alongside the type boundary
- Good, because the pattern matches
taxonomy-criteria.md, keeping documentation consistent - Good, because the criteria compose with the relationship type system (ADR-0011) – the decision tree references relationship types as outputs
- Bad, because edge cases will inevitably arise that the criteria do not cover – the document must evolve via RFC
- Bad, because retroactive reclassification of imported data may be needed as criteria are refined
Implementation
The companion documentation page at docs/src/pillars/data-model/entity-type-criteria.md contains:
- Summary table of the six entity types with defining questions and characteristics
- Primary decision tree (mermaid flowchart) for entity type classification
- Entity vs edition decision tree for the new-entity/new-edition boundary
- Worked examples for all six types (clear cases) and six grey zone boundaries
- Seven numbered grey zone rules with explicit tests
- BGG migration mapping table with deterministic import rules
- Nine-item RFC reviewer checklist for entity classification review
status: proposed date: 2026-03-16
ADR-0043: Player Count Sentiment Model Improvements
Context and Problem Statement
The current PlayerCountPoll model (ADR-0010) uses BGG’s three-tier voting system: for each player count, voters choose one of Best, Recommended, or Not Recommended. This system was adopted for BGG migration compatibility, but it has fundamental statistical flaws that the commons group should address as the specification matures.
Known Flaws in the Three-Tier Model
1. Overlapping categories. “Best” is conceptually a subset of “Recommended” – if a player count is the best, it is by definition recommended. But the poll treats them as mutually exclusive choices. A voter who considers 3-player the best experience cannot simultaneously mark it as recommended. The categories answer different questions (“Is this the ideal count?” vs “Is this count good?”) but force a single answer.
2. Missing middle ground. There is no option between “Recommended” and “Not Recommended.” A player count that is playable but mediocre – works fine, wouldn’t seek it out – has no natural home. Voters are forced to round up to “Recommended” or round down to “Not Recommended,” inflating or deflating the signal.
3. Anchoring bias. The boundary between “Best” and “Recommended” is entirely subjective and varies per voter. One voter’s “Best” is another’s “Recommended.” There is no calibration mechanism – unlike the weight scale (which has anchor games), the poll categories have no reference points.
4. Forced ranking. A voter who thinks 3-player and 4-player are equally excellent must choose “Best” for one and “Recommended” for the other. The model cannot express ties at the top. This creates artificial differentiation where none exists in the voter’s actual opinion.
5. Non-independence across player counts. A voter’s responses at different player counts are not independent decisions. Voters mentally rank all player counts, then map that ranking onto three buckets. The three-tier model treats each player count as an independent poll, but the data-generating process is inherently comparative.
6. Aggregation artifacts. A game where 80% of voters say “Best at 3” and a different 80% say “Best at 4” appears to have two equally “best” counts. But no individual voter may actually consider both counts equally best – the aggregate masks disagreement. Without per-voter data, the source of the pattern is unrecoverable.
Decision Drivers
- BGG migration requires preserving three-tier data for the foreseeable future – any improvement must be backward-compatible
- Statistical soundness: the replacement model should produce data amenable to standard statistical analysis (means, medians, distributions)
- UI simplicity: the voting interface must be intuitive for casual users, not just statisticians
- Community adoption: the model must be easy to contribute to – a complex system that nobody uses is worse than a flawed one with millions of votes
- The 33+ files across the specification that reference the current model represent significant refactoring cost for any structural change
Considered Options
Option A: Numeric Rating Per Player Count (1-5 Scale)
Each voter independently rates each supported player count on a 1-5 scale:
| Player Count | Your Rating |
|---|---|
| 1 | 2 / 5 |
| 2 | 4 / 5 |
| 3 | 5 / 5 |
| 4 | 5 / 5 |
| 5 | 3 / 5 |
Strengths:
- Produces real numeric distributions (mean, median, std dev, percentiles) per player count
- A voter CAN rate 3p and 4p both 5/5 – no forced ranking
- Aligns with how BGG already handles overall game ratings (the 1-10 scale)
- Independent per player count – no cross-count comparison forced
- Standard statistical tools apply directly
Weaknesses:
- Requires a new UI paradigm (5-point scale per count vs single radio button)
- Not backward-compatible with existing BGG three-tier data
- Scale calibration: what does “3 out of 5” mean? Needs anchor definitions.
Option B: Pairwise Preference / Ranked Choice
Voters rank all supported player counts from best to worst. Aggregation uses a Condorcet method, Borda count, or similar social choice function.
Strengths:
- Most statistically rigorous – captures full preference ordering
- No category overlap or forced bucketing
- Well-studied aggregation methods from voting theory
Weaknesses:
- Complex to aggregate and explain to users
- Difficult UI for games with wide player ranges (ranking 1-8 is tedious)
- Unfamiliar paradigm – most users have never seen ranked-choice voting for board game data
- No established board game community uses this approach
Option C: Binary Per Count (Would Play / Would Not Play)
For each player count, a single yes/no question: “Would you play this game at this player count?”
Strengths:
- Simplest possible signal – no ambiguity, no overlap
- Eliminates the Best/Recommended boundary problem entirely
- Easy to aggregate: percentage of “yes” votes per count
Weaknesses:
- Loses all granularity between “great” and “fine”
- Cannot distinguish “best at 3” from “acceptable at 3”
- The filtering use case (“best at exactly 3”) becomes impossible
Option D: Dual-Layer Model
Maintain two parallel data layers:
- Layer 1 (BGG compatibility): The existing three-tier votes (best/recommended/not_recommended). Populated during BGG migration and by voters who prefer the familiar interface.
- Layer 2 (native): A numeric 1-5 rating per player count. The statistically preferred data source for new contributions.
Filtering uses Layer 2 when sufficient data exists, falling back to Layer 1. Over time, as native contributions accumulate, Layer 2 becomes the authoritative source.
Strengths:
- Full backward compatibility – no existing data is lost or invalidated
- Gradual migration path – both layers coexist indefinitely
- Layer 2 produces proper statistical distributions while Layer 1 serves migration needs
Weaknesses:
- Two parallel systems increase complexity for implementations and API consumers
- Unclear when to declare Layer 2 “sufficient” and deprioritize Layer 1
- Voters may be confused by two different rating interfaces
Decision Outcome
Chosen option: “Dual-layer model” (Option D), adopting numeric per-count ratings as the native model with BGG three-tier data preserved as a legacy migration layer.
The specification defines PlayerCountRating as the primary schema: each voter independently rates each supported player count on a 1-5 scale, producing standard statistical distributions (mean, std dev) per count. The BGG three-tier data (Best/Recommended/Not Recommended) is preserved as PlayerCountPollLegacy for migration compatibility (ADR-0032). Filtering and derived fields use the numeric model when available, falling back to converted legacy data.
Numeric per-count ratings were chosen over ranked choice (Option B, too complex for voters) and binary would-play (Option C, loses granularity). The pure numeric approach (Option A) is effectively what the dual-layer model implements as its native layer – Option D simply adds the legacy compatibility that migration requires.
Consequences
- Good, because the native model uses standard numeric data amenable to means, medians, percentiles, and confidence intervals
- Good, because voters can rate multiple player counts equally – no forced ranking, no overlapping categories
- Good, because BGG migration data is preserved without loss – the legacy schema stores the original three-tier votes
- Good, because the specification transparently acknowledges the limitations of the inherited model
- Good, because implementations can convert legacy three-tier data to approximate numeric values for unified querying
- Bad, because two parallel schemas (native + legacy) increase complexity for implementations
- Bad, because the numeric scale lacks anchor definitions (unlike the weight scale) – a future RFC should define what 1-5 means for player count quality
- Bad, because applications built against the old three-tier
PlayerCountPollschema will need to update toPlayerCountRating
status: proposed date: 2026-03-16
ADR-0044: Player Entity and Collection Data
Context and Problem Statement
The specification models games comprehensively – entities, relationships, expansions, editions, taxonomy – and captures community opinions about games via ratings, weight votes, player count assessments, and play time logs. But it does not model the people who hold those opinions.
Every data quality problem documented across the rating model, weight model, and player count model traces to the same root cause: all voters are treated as interchangeable. A first-time casual gamer’s weight vote counts the same as a 20-year veteran’s. A voter who uses a 1-5 rating scale is averaged with one who uses 6-10. A rating from someone who played once is indistinguishable from someone who played 50 times. Without a Player entity, these differences are invisible.
Additionally, collection data (owned, wishlisted, for trade) and play logs are currently proposed as aggregate counts on the Game entity (ADR-0041), but they are fundamentally per-player data. The aggregate is derived from the individual; the specification should model both.
Decision Drivers
- Every community-sourced metric (rating, weight, player count, playtime) is an opinion from a specific person with specific context – modeling that person enables vote weighting, bias detection, and corpus-based analysis
- The voter-declared scale in the Rating Model is a per-player attribute, not per-vote – it belongs on a persistent Player entity
- Collection states (owned, wishlist, for_trade) are per-player relationships to games, not game-level properties – the game-level aggregates in ADR-0041 are derived from this underlying data
- Play logs with context (player count, experience level, duration, expansions used) are the foundation for community playtime data, experience-bucketed playtime (ADR-0034), and engagement metrics
- Corpus-based filtering (“what do players like me think?”) is a fundamentally more useful question than “what does the undifferentiated crowd think?” – and it requires knowing who the voters are
- Privacy must be opt-in: anonymous voting remains valid, Player profiles are voluntary
Considered Options
- No Player entity – Continue treating all voters as interchangeable; capture per-vote context metadata without linking to a persistent identity
- Minimal Player entity – ID, username, declared preferences, collection states, play logs; no derived profiles or archetypes
- Full Player entity with derived taste profiles and archetype clustering – Persistent identity with collection, play history, declared preferences, plus derived behavioral profiles
Decision Outcome
Chosen option: “Full Player entity with derived taste profiles and archetype clustering,” because the value of Player data is primarily in the derived insights (taste profiles, archetype clustering, corpus-based filtering), not just the raw fields. A minimal entity without derived attributes would store collection data but miss the analytical potential that motivated this ADR.
The per-vote context metadata approach was rejected because context without identity loses continuity – a voter’s scale preference, experience trajectory, and taste profile are longitudinal attributes that only make sense when linked across votes over time.
Consequences
- Good, because every community-sourced metric can be contextualized by who submitted it – enabling vote weighting, bias quantification, and audience segmentation
- Good, because the voter-declared rating scale has a natural home on the Player entity rather than being repeated per-vote
- Good, because collection data and play logs are modeled at the correct level (per-player) with game-level aggregates derived from them
- Good, because corpus-based filtering (“what do players like me think?”) becomes possible – a capability no existing board game API offers
- Good, because archetype-based analysis gives publishers real audience insight (“what kind of player rates my game highly?”)
- Bad, because Player data introduces privacy obligations – the specification must define opt-in, anonymization, and data deletion principles
- Bad, because derived attributes (taste profiles, archetypes) require implementation-level computation that the spec can describe but not mandate
- Bad, because the entity significantly expands the specification’s scope from “game data” to “game data + user data”
Implementation
See Players & Collections for the full entity documentation including:
- Player entity fields
- Collection states and demand signal derivation
- Play log schema
- Taste profile derivation
- Archetype clustering
- Privacy principles
- Relationship to existing models (rating, weight, player count, playtime, age recommendation)
status: accepted date: 2026-03-12
ADR-0002: Use REST with OpenAPI 3.2 as the API Protocol
Context and Problem Statement
The OpenTabletop project defines a public specification for board game data interchange. We need to choose an API protocol and contract format that maximizes adoption across diverse consumers – web frontends, mobile apps, data science pipelines, and third-party integrations. The specification must serve as the single source of truth, and the design process must be spec-first so that implementations conform to the spec rather than the other way around.
Decision Drivers
- Broadest possible client compatibility across languages and platforms
- Machine-readable specification that can generate documentation, SDKs, and validation
- Spec-first design workflow where the OpenAPI document is authored before implementation
- Low barrier to entry for hobbyist and community developers
Considered Options
- REST with OpenAPI 3.2 specification
- GraphQL with schema-first SDL
- gRPC with Protocol Buffers
Decision Outcome
Chosen option: “REST with OpenAPI 3.2”, because REST has the broadest compatibility across every HTTP client in every language with zero special tooling required. OpenAPI 3.2 is the industry standard for describing REST APIs, enabling automatic SDK generation, interactive documentation, and contract testing. GraphQL adds query flexibility but introduces complexity around caching, authorization per field, and requires specialized clients. gRPC excels at service-to-service communication but is poorly suited for browser-based consumers and public APIs. The OpenAPI specification document is the canonical source of truth; implementations are validated against it.
Consequences
- Good, because any HTTP client can consume the API without specialized libraries
- Good, because OpenAPI enables automatic SDK generation, documentation, and mock servers
- Good, because spec-first design ensures the contract is stable and well-defined before coding begins
- Bad, because REST requires multiple round-trips for related resources where GraphQL could fetch in one request (mitigated by selective embedding via ?include parameter)
- Bad, because OpenAPI 3.2 is newer and some tooling may lag behind 3.0/3.1 support
status: accepted date: 2026-03-12
ADR-0012: Keyset (Cursor-Based) Pagination
Context and Problem Statement
The OpenTabletop API serves collections that can contain tens of thousands of games. Pagination is essential, but the choice of pagination strategy has significant implications for performance, consistency, and usability. We need a pagination approach that performs consistently regardless of collection size and handles concurrent data changes gracefully.
Decision Drivers
- Performance must not degrade as the user pages deeper into results (page 1000 should be as fast as page 1)
- Results must be consistent even when new records are inserted during pagination
- Pagination tokens should be opaque to discourage clients from constructing or manipulating them
- The approach must work efficiently with PostgreSQL’s query planner
Considered Options
- Offset-based pagination (OFFSET/LIMIT)
- Keyset/cursor-based pagination
- Page-number pagination (page=N&per_page=M)
Decision Outcome
Chosen option: “Keyset/cursor-based pagination”, because it provides O(1) consistent performance regardless of how deep into the result set the user has paged. The API returns next_cursor and prev_cursor as opaque Base64-encoded tokens in the response metadata. Clients pass these as query parameters to fetch the next or previous page. The default page size is 25, the maximum is 100, configurable via the limit parameter. Offset-based pagination was rejected because OFFSET N requires scanning and discarding N rows, causing linear performance degradation on deep pages. Page-number pagination was rejected for the same underlying performance issue since it is functionally equivalent to offset pagination.
Consequences
- Good, because query performance is constant regardless of page depth
- Good, because keyset pagination is immune to phantom reads (inserted/deleted rows don’t shift pages)
- Good, because opaque cursors decouple the client from the underlying sort implementation
- Bad, because clients cannot jump to an arbitrary page (e.g., “page 50 of 200”) without sequential traversal
- Bad, because changing the sort order invalidates existing cursors, requiring clients to restart pagination
status: accepted date: 2026-03-12
ADR-0013: Compound Multi-Dimensional Filtering as Core Feature
Context and Problem Statement
The central value proposition of the OpenTabletop API is enabling consumers to answer questions like “What are the best worker-placement games for exactly 3 players that play in under 90 minutes with medium-heavy weight?” This requires composable multi-dimensional filtering across six dimensions simultaneously. The filtering system is not just a feature – it is THE core feature that differentiates this API from existing board game data sources.
Decision Drivers
- Filtering must compose across all six dimensions: player count, playtime, weight, mechanics/type, themes, and metadata
- Expansion-aware filtering (effective=true) must resolve combined properties per ADR-0007
- Simple use cases (single filter) must remain simple; complex queries should be possible but not required
- The query interface must be expressible both as GET query parameters and as structured POST bodies
Considered Options
- Simple parameter-based filters (player_count=3&max_playtime=90)
- Custom query language (DSL parsed from a query string)
- Compound composable filters with GET for simple queries and POST /games/search for complex queries
Decision Outcome
Chosen option: “Compound composable filters with dual GET/POST interface”, because it keeps simple queries simple while enabling arbitrarily complex multi-dimensional filtering. Simple filters use GET query parameters on /games (e.g., ?min_players=3&max_playtime=90&mechanic=worker-placement). Complex queries that combine multiple values per dimension, boolean logic, or expansion-aware resolution use POST /games/search with a structured JSON body. The effective=true parameter triggers expansion-aware property resolution per ADR-0007’s three-tier model. A custom DSL was rejected because it requires clients to learn a proprietary query syntax. Simple parameters alone were rejected because they cannot express compound conditions within a single dimension (e.g., “mechanic is worker-placement AND area-control”).
Consequences
- Good, because simple filtering via GET parameters requires zero learning curve
- Good, because POST /games/search enables arbitrarily complex queries with a structured, validatable JSON body
- Good, because expansion-aware filtering via effective=true is a unique differentiator for this API
- Bad, because two query interfaces (GET and POST) mean two code paths to maintain and document
- Bad, because expansion-aware filtering requires additional query complexity and may be slower than base-only filtering
status: accepted date: 2026-03-12
ADR-0015: RFC 9457 Problem Details for Error Responses
Context and Problem Statement
A well-designed API needs a consistent, machine-readable error response format. Clients must be able to programmatically distinguish error types, extract human-readable messages, and handle validation failures with field-level detail. We need an error format that is standardized, extensible, and compatible with HTTP semantics.
Decision Drivers
- Error responses must be machine-parseable with a consistent structure across all endpoints
- Validation errors must include field-level detail (which field, what constraint, what value)
- The format should be an established standard to avoid inventing a proprietary error schema
- The format must support extension fields for domain-specific error information
Considered Options
- Custom error format specific to the project
- RFC 7807 Problem Details for HTTP APIs
- RFC 9457 Problem Details for HTTP APIs (supersedes RFC 7807)
Decision Outcome
Chosen option: “RFC 9457 Problem Details”, because it is the current IETF standard for HTTP API error responses, superseding RFC 7807 with clarifications and improvements. Every error response uses the application/problem+json media type and includes the standard fields: type (URI identifying the error kind), title (human-readable summary), status (HTTP status code), detail (human-readable explanation specific to this occurrence), and instance (URI identifying this specific occurrence). For validation errors, we extend with a custom errors array containing objects with field, constraint, and message properties. RFC 7807 was rejected because RFC 9457 supersedes it. A custom format was rejected because it would require every client to learn a proprietary schema.
Consequences
- Good, because RFC 9457 is an IETF standard that many HTTP libraries already understand
- Good, because the
typeURI enables programmatic error handling without string matching on messages - Good, because extension fields allow rich validation error details without breaking the standard format
- Bad, because RFC 9457 is relatively new and some older tooling may only recognize RFC 7807
- Bad, because the
typeURI field requires maintaining a registry of error type URIs
status: accepted date: 2026-03-12
ADR-0016: API Key Authentication with Tiered Rate Limits
Context and Problem Statement
The OpenTabletop API must balance open access for reading public data with protection against abuse and accountability for write operations. The authentication and rate-limiting strategy must be simple enough that hobbyist developers can start using the API immediately while providing enough control to prevent abuse and track usage patterns.
Decision Drivers
- Reading public game data should have the lowest possible barrier to entry
- Write operations (data contributions, corrections) require accountability
- Rate limiting must prevent abuse without blocking legitimate use
- The solution must be simple to implement and understand – no OAuth flows for basic usage
Considered Options
- No authentication – fully open with IP-based rate limiting only
- API key required for all access
- Tiered access – public reads with IP rate limiting, authenticated reads and writes with API key
Decision Outcome
Chosen option: “Tiered access with API key authentication”, because it minimizes friction for data consumers while maintaining accountability for data contributors. The three tiers are: (1) Public tier – no authentication required, IP-based rate limiting at 60 requests per minute, read-only access; (2) Authenticated tier – API key via X-API-Key header, 600 requests per minute, read-only access; (3) Write tier – API key required, write operations for data contributions and corrections. API keys are free and self-service via the developer portal. OAuth 2.0 is documented as a future enhancement for user-delegated access scenarios. Full-open was rejected because write operations need accountability. API-key-for-all was rejected because it raises the barrier for simple read-only consumers.
Consequences
- Good, because hobbyist developers can start querying the API immediately with zero signup
- Good, because the 10x rate limit increase for authenticated users incentivizes key registration
- Good, because API keys enable per-consumer usage tracking and abuse detection
- Bad, because IP-based rate limiting for the public tier can affect users behind shared NAT/proxies
- Bad, because API keys alone do not support user-delegated access patterns (addressed by future OAuth 2.0)
status: accepted date: 2026-03-12
ADR-0017: Selective Resource Embedding via ?include Parameter
Context and Problem Statement
Game resources have many related entities – mechanics, designers, publishers, expansions, player count polls, and more. Always embedding all related data in every response wastes bandwidth and increases latency. Never embedding forces clients into N+1 request patterns. We need a middle ground that lets clients request exactly the related resources they need in a single request.
Decision Drivers
- Clients have varying needs – a list view needs minimal data, a detail view needs rich data
- Over-fetching wastes bandwidth, especially on mobile connections
- Under-fetching forces N+1 request patterns that increase latency
- The mechanism should be simple and self-documenting via OpenAPI
Considered Options
- Always embed all related resources in every response
- Never embed – clients must make separate requests for each related resource
- Selective embedding via
?includequery parameter
Decision Outcome
Chosen option: “Selective embedding via ?include parameter”, because it gives clients precise control over response payload size while enabling single-request access to related resources. The ?include parameter accepts a comma-separated list of relationship names (e.g., ?include=mechanics,designers,expansions). When specified, the related resources are embedded in the response under their respective keys. When omitted, only the core game fields and _links (see ADR-0018) are returned. The set of valid include values is documented per endpoint in the OpenAPI spec. Always-embed was rejected because it makes list endpoints prohibitively expensive. Never-embed was rejected because it forces clients into inefficient multi-request patterns.
Consequences
- Good, because clients fetch exactly the data they need in a single request
- Good, because list endpoints remain lightweight by default
- Good, because the valid include values are self-documenting in the OpenAPI spec
- Bad, because the server must handle dynamic JOIN/query generation based on the include parameter
- Bad, because deeply nested includes (e.g., expansions of expansions) require depth limits to prevent performance issues
status: accepted date: 2026-03-12
ADR-0018: HAL-Style Hypermedia Links for Discoverability
Context and Problem Statement
A well-designed REST API should be discoverable – clients should be able to navigate the API by following links in responses rather than constructing URLs from documentation. However, full HATEOAS (Hypermedia as the Engine of Application State) adds significant complexity with marginal benefit for most API consumers. We need a pragmatic level of hypermedia support that aids discoverability without over-engineering.
Decision Drivers
- Responses should include links to related resources and pagination endpoints
- The link format should be a recognized standard, not a custom invention
- Full HATEOAS state machine semantics are overkill for a data-oriented API
- Links must be useful for both human developers exploring the API and automated clients
Considered Options
- No hypermedia links – clients construct URLs from documentation
- HAL (Hypertext Application Language) format links
- JSON:API links format
- Full HATEOAS with state transitions and actions
Decision Outcome
Chosen option: “HAL-style links (HAL-lite)”, because HAL’s _links object is simple, well-understood, and provides the right level of discoverability without the complexity of full HATEOAS. Every response includes a _links object containing at minimum a self link. Collection responses include next and prev pagination links when applicable. Resource responses include links to related resources (e.g., expansions, designers, mechanics). We adopt HAL’s link format (href property) without the full HAL specification (no _embedded, no link relations registry, no CURIEs). JSON:API was rejected because it imposes a full response envelope format that constrains our schema design. Full HATEOAS was rejected because state machine semantics add complexity that data API consumers do not need.
Consequences
- Good, because developers can explore the API by following links in responses
- Good, because HAL’s
_linksformat is widely recognized and trivially parseable - Good, because pagination links eliminate the need for clients to construct cursor URLs manually
- Bad, because HAL-lite is not a formal specification, so the exact link behavior must be documented
- Bad, because including
_linksin every response adds payload overhead, even when clients ignore them
status: accepted date: 2026-03-12
ADR-0019: Bulk Data Export Endpoints
Context and Problem Statement
Data scientists, researchers, and application developers who need the complete dataset should not have to paginate through thousands of API requests to build a local copy. The API needs a bulk export mechanism that provides the full dataset efficiently in formats suitable for data analysis pipelines. This is distinct from the paginated API, which is optimized for interactive use.
Decision Drivers
- Data science workflows need the complete dataset in analysis-friendly formats
- Bulk export should not compete with interactive API traffic for resources
- Export formats must be widely supported by data tools (pandas, R, SQL imports)
- The export mechanism should include metadata about freshness and completeness
Considered Options
- No bulk export – consumers must paginate through the full API
- Paginate-all pattern with a special “dump” mode on existing endpoints
- Dedicated export endpoints with streaming responses and manifest metadata
Decision Outcome
Chosen option: “Dedicated export endpoints with ExportManifest”, because they provide a purpose-built interface for bulk data access that does not compromise the interactive API’s performance or design. The GET /export/games endpoint supports JSON Lines (default) and CSV via content negotiation (Accept header). Each export response includes an ExportManifest header with fields: total_records, export_timestamp, spec_version, and checksum. JSON Lines format is used instead of a single JSON array to enable streaming processing without loading the entire response into memory. CSV is offered for spreadsheet and SQL import workflows. Parquet format is documented as a future enhancement via content negotiation. The paginate-all approach was rejected because it overloads the interactive API’s pagination semantics with a fundamentally different use case.
Consequences
- Good, because data scientists can download the complete dataset in a single streaming request
- Good, because JSON Lines enables memory-efficient streaming processing
- Good, because the ExportManifest provides the metadata needed for data pipeline integrity checks
- Bad, because dedicated export endpoints duplicate some logic from the main API endpoints
- Bad, because large exports consume significant server resources and may need to be rate-limited separately
status: accepted date: 2026-03-12
ADR-0028: Cache-Control Headers and ETags
Context and Problem Statement
Board game metadata changes infrequently – a game’s mechanics, player count, and description rarely update after initial entry. The API should leverage HTTP caching to reduce server load, decrease response latency for repeat requests, and enable CDN integration. We need a caching strategy that matches the data’s update frequency while ensuring clients eventually receive fresh data.
Decision Drivers
- Game metadata is read-heavy and write-rare, making it an ideal caching candidate
- Dynamic data (e.g., community playtime polls, ratings) changes more frequently and needs shorter cache windows
- CDN and browser caches should be leverageable without custom integration
- Clients must have a mechanism to validate cached data without re-downloading (conditional requests)
Considered Options
- No caching – every request hits the origin server
- Server-side caching only (Redis/Memcached) with no client-side cache hints
- HTTP cache headers (Cache-Control and ETag) for client and CDN caching
Decision Outcome
Chosen option: “HTTP Cache-Control headers and ETags”, because they leverage the entire HTTP caching infrastructure (browsers, CDNs, reverse proxies) without any custom client-side logic. Game metadata responses include Cache-Control: public, max-age=86400 (24 hours) since this data changes rarely. Dynamic data (poll results, community playtimes) uses Cache-Control: public, max-age=300 (5 minutes). All responses include an ETag header (based on content hash) enabling conditional requests via If-None-Match – the server returns 304 Not Modified when the content has not changed, saving bandwidth. No-caching was rejected because it wastes resources on a read-heavy, write-rare dataset. Server-side-only caching was rejected because it misses the opportunity to eliminate requests entirely via client and CDN caches.
Consequences
- Good, because CDNs can cache responses at edge locations, reducing latency globally
- Good, because ETags enable conditional requests, saving bandwidth when data has not changed
- Good, because standard HTTP caching requires no custom client implementation
- Bad, because stale cache entries may serve outdated data for up to the max-age duration
- Bad, because cache invalidation on data updates requires careful consideration of cache-busting strategies
status: accepted date: 2026-03-12
ADR-0020: Twelve-Factor Application Design
Context and Problem Statement
The OpenTabletop reference server must be deployable across diverse environments – local development, CI/CD pipelines, cloud VMs, and Kubernetes clusters. The deployment and runtime model should follow established best practices that ensure portability, scalability, and operational simplicity. We need an architectural philosophy that guides operational decisions consistently.
Decision Drivers
- The reference server must run identically in development and production environments
- Configuration must be externalized and environment-specific, not baked into the binary
- The application must be stateless and horizontally scalable
- The design should align with modern container orchestration platforms
Considered Options
- Traditional deployment with configuration files and persistent server state
- Container-first design with 12-factor principles
- Serverless / Function-as-a-Service architecture
Decision Outcome
Chosen option: “Container-first with 12-factor principles”, because the twelve-factor methodology provides battle-tested guidelines for building cloud-native applications that are portable, scalable, and operationally sound. Specifically: configuration is read exclusively from environment variables (Factor III); the application binds to a port specified by the PORT environment variable (Factor VII); processes are stateless and share nothing (Factor VI); the application starts fast and shuts down gracefully on SIGTERM (Factor IX); development and production use the same backing services and dependencies (Factor X). Serverless was rejected because the API’s connection pooling and in-memory caching patterns are a poor fit for ephemeral function instances. Traditional deployment was rejected because it couples the application to specific infrastructure.
Consequences
- Good, because the application runs identically across all environments with only environment variable differences
- Good, because stateless processes enable horizontal scaling by simply adding instances
- Good, because graceful shutdown and fast startup support zero-downtime deployments
- Bad, because strict adherence to 12-factor (e.g., no local filesystem state) requires external services for features like file-based caching
- Bad, because environment variable configuration can become unwieldy with many settings (mitigated by structured naming conventions)
status: accepted date: 2026-03-12
ADR-0021: Distroless Container Images
Context and Problem Statement
The reference server is distributed as a container image. The base image choice affects image size, attack surface, build time, and debugging capabilities. We need a base image strategy that minimizes security risk and image size while still supporting the operational needs of a production service (health checks, graceful shutdown, signal handling).
Decision Drivers
- Minimal attack surface – fewer packages mean fewer CVE exposure points
- Small image size for fast pulls and reduced storage costs
- The container must support health check endpoints and signal handling
- Multi-stage builds should produce a clean separation between build and runtime artifacts
Considered Options
- Alpine Linux base image
- Debian slim base image
- Google distroless base image
Decision Outcome
Chosen option: “Distroless base image with multi-stage Dockerfile”, because it provides the smallest possible attack surface by containing only the application binary, its runtime dependencies, and CA certificates – no shell, no package manager, no utilities that an attacker could exploit. The Dockerfile uses a multi-stage build: the first stage uses a full Rust toolchain image for compilation, and the final stage copies only the compiled binary into gcr.io/distroless/cc-debian12. The application exposes /healthz (liveness – returns 200 if the process is running) and /readyz (readiness – returns 200 if the database connection pool is healthy) health endpoints. The application handles SIGTERM for graceful shutdown, draining in-flight requests before exiting. Alpine was rejected because musl libc can cause subtle compatibility issues with some Rust crates. Debian slim was rejected because it includes a shell and package manager that increase attack surface unnecessarily.
Consequences
- Good, because the runtime image contains no shell, package manager, or unnecessary utilities
- Good, because image size is minimal (typically under 30MB for a Rust binary)
- Good, because health endpoints enable Kubernetes liveness and readiness probes
- Bad, because distroless images cannot be exec’d into for debugging (mitigated by using a debug variant in staging)
- Bad, because the lack of a shell means troubleshooting must be done via application logs and external tooling
status: accepted date: 2026-03-12
ADR-0022: Kubernetes-Native Deployment with Docker Compose Fallback
Context and Problem Statement
The reference server needs deployment manifests that work for both production Kubernetes clusters and local development environments. Kubernetes is the dominant container orchestration platform, but requiring it for local development creates an unnecessary barrier. We need a deployment strategy that targets Kubernetes as the primary platform while keeping local development simple.
Decision Drivers
- Production deployments should leverage Kubernetes features (rolling updates, HPA, service discovery)
- Local development should not require a Kubernetes cluster
- Deployment manifests should be version-controlled and reproducible
- The deployment model should support both single-instance and horizontally-scaled configurations
Considered Options
- Bare metal deployment with systemd units
- Docker Compose as the sole deployment target
- Kubernetes-native manifests with Docker Compose fallback for local development
Decision Outcome
Chosen option: “Kubernetes-native with Docker Compose fallback”, because Kubernetes provides the scaling, self-healing, and deployment features needed for production while Docker Compose offers the simplest possible local development experience. The project ships Kubernetes manifests including: Deployment (with resource limits, health probes, and rolling update strategy), Service (ClusterIP), Ingress (with TLS), ConfigMap (for non-secret configuration), and HorizontalPodAutoscaler (CPU/memory-based scaling). For local development, a docker-compose.yml provides the application, PostgreSQL, and optional observability stack with a single docker compose up command. Bare metal was rejected because it couples deployment to specific OS and infrastructure. Docker Compose alone was rejected because it lacks production-grade orchestration features.
Consequences
- Good, because Kubernetes manifests enable production-grade deployment with scaling, health checks, and rolling updates
- Good, because Docker Compose provides a zero-config local development experience
- Good, because both deployment targets use the same container image, ensuring environment parity
- Bad, because maintaining two sets of deployment configuration (K8s manifests and docker-compose.yml) requires keeping them in sync
- Bad, because Kubernetes manifests add complexity that smaller deployments may not need
status: accepted date: 2026-03-12
ADR-0023: OpenTelemetry for Unified Observability
Context and Problem Statement
Operating the reference server requires visibility into application behavior through three pillars of observability: logs, metrics, and traces. These signals must be collected, exported, and correlated to diagnose issues effectively. We need an observability strategy that is vendor-neutral, standards-based, and provides unified correlation across all three signal types.
Decision Drivers
- Observability must cover all three pillars: structured logs, metrics, and distributed traces
- The solution must be vendor-neutral – operators should choose their own backends (Grafana, Datadog, etc.)
- Trace IDs must correlate across logs, metrics, and traces for unified debugging
- The instrumentation overhead must be minimal in production
Considered Options
- Custom structured logging with application-specific metrics
- ELK stack (Elasticsearch, Logstash, Kibana) as a coupled observability solution
- OpenTelemetry for vendor-neutral, unified observability
Decision Outcome
Chosen option: “OpenTelemetry”, because it provides a single, vendor-neutral standard for all three observability pillars with built-in correlation. Structured JSON logging includes trace IDs and span IDs in every log line, enabling correlation with distributed traces. Prometheus-format metrics are exposed at /metrics for scraping, covering request latency histograms, request counts by endpoint and status, active connection gauges, and database pool metrics. Distributed traces use the OTEL SDK with configurable exporters (OTLP, Jaeger, Zipkin). All three signals share the same trace context, so a single trace ID links a log entry to its metric dimensions and trace span. ELK was rejected because it couples the application to a specific backend stack. Custom logging was rejected because it lacks standardized correlation across signals.
Consequences
- Good, because operators can use any OTEL-compatible backend (Grafana+Tempo, Datadog, Honeycomb, etc.)
- Good, because trace-correlated logs enable end-to-end request debugging across services
- Good, because Prometheus metrics enable standard alerting and dashboarding workflows
- Bad, because the OTEL SDK adds a runtime dependency and some performance overhead to every request
- Bad, because configuring exporters and sampling strategies adds operational complexity
status: accepted date: 2026-03-12
ADR-0024: Immutable Infrastructure with Blue-Green Deployment
Context and Problem Statement
Deploying updates to the reference server must be safe, reproducible, and reversible. Mutable infrastructure (patching running servers) leads to configuration drift, unreproducible environments, and risky rollbacks. We need a deployment philosophy that ensures every deployment is a clean, known state and that rollbacks are trivial.
Decision Drivers
- Every deployment must be reproducible from a known, versioned artifact
- Rollbacks must be instant and safe – no partial state between versions
- Configuration drift between environments must be eliminated
- The deployment pipeline must support canary and blue-green release strategies
Considered Options
- Mutable servers with in-place patching and configuration management (Ansible, Chef)
- Immutable container images tagged by git SHA with blue-green deployment
- Full GitOps with reconciliation controllers (ArgoCD, Flux)
Decision Outcome
Chosen option: “Immutable container images with blue-green deployment”, because it guarantees that every deployment runs exactly the same artifact that was tested in CI. Container images are tagged with the git SHA that produced them – never latest, never mutable tags. No runtime patching, no SSH-and-fix. To deploy a new version, the blue-green strategy routes traffic to a new set of instances running the new image while the old instances remain available for instant rollback. Canary deployment (routing a percentage of traffic to the new version) is supported as an alternative for high-risk changes. Mutable servers were rejected because they inevitably lead to configuration drift. Full GitOps was rejected as overkill for the initial project scope, though it is a natural evolution path.
Consequences
- Good, because every deployment is reproducible – the git SHA uniquely identifies exactly what is running
- Good, because rollbacks are instant – just route traffic back to the previous image
- Good, because configuration drift is impossible when servers are never mutated
- Bad, because every change, no matter how small, requires building and deploying a new container image
- Bad, because blue-green deployment doubles the infrastructure cost during deployment windows
status: superseded by ADR-0045 date: 2026-03-12
ADR-0025: Rust with Axum and SQLx for the Reference Server
Context and Problem Statement
The OpenTabletop specification needs a reference server implementation that demonstrates the full API contract, serves as a conformance test target, and can be deployed in production. The technology choice for this reference server must prioritize correctness, performance, and long-term maintainability. Critically, the reference server is one valid implementation of the specification – not the only one – and should not be conflated with the spec itself.
Decision Drivers
- The reference server must faithfully implement the full OpenAPI specification
- Type safety and compile-time guarantees reduce runtime bugs in a specification-critical codebase
- Performance should be excellent out of the box without extensive tuning
- The SQL layer should be checked at compile time to catch query errors before deployment
- The technology should have a strong, growing ecosystem and community
Considered Options
- actix-web with Diesel ORM
- Axum with SQLx (compile-time checked queries)
- Rocket with SeaORM
Decision Outcome
Chosen option: “Axum with SQLx and PostgreSQL”, because Axum’s macro-free, tower-based architecture provides excellent composability and testability while SQLx’s compile-time SQL checking catches query errors at build time rather than runtime. Axum is built on tokio and tower, making it fully interoperable with the broader async Rust ecosystem without custom runtime requirements. SQLx’s query! and query_as! macros verify SQL syntax and type compatibility against the actual database schema during compilation, which is invaluable for a reference implementation that must be correct. PostgreSQL is the database (see ADR-0027) for its full-text search and JSONB capabilities. actix-web was rejected because its macro-heavy API and custom runtime add unnecessary coupling. Rocket was rejected because its compile-time checking is less mature than SQLx’s.
Consequences
- Good, because compile-time SQL verification eliminates an entire class of runtime query errors
- Good, because Axum’s tower-based middleware is composable, testable, and interoperable with the async Rust ecosystem
- Good, because Rust’s performance characteristics mean the reference server can also serve as a production server
- Bad, because Rust’s learning curve is steeper than languages like Python or Go, potentially limiting contributor diversity
- Bad, because compile-time SQL checking requires a running database during the build process (mitigated by SQLx’s offline mode)
status: superseded by ADR-0045 date: 2026-03-12
ADR-0026: OpenAPI Generator for SDK Generation
Context and Problem Statement
The OpenTabletop API should be easy to consume from multiple programming languages. Providing official SDKs lowers the barrier to adoption, but hand-writing and maintaining SDKs for multiple languages is expensive and error-prone – especially when the specification evolves. We need an SDK strategy that keeps SDKs in sync with the spec while allowing ergonomic customization.
Decision Drivers
- SDKs must stay in sync with the OpenAPI specification as it evolves
- Initial SDK generation must be automated to reduce maintenance burden
- Generated code should be ergonomic and idiomatic for each target language
- The SDK toolchain must support Rust, Python, and JavaScript/TypeScript as priority targets
Considered Options
- Hand-written SDKs maintained independently per language
- openapi-generator for automated SDK generation with hand-tuned ergonomic wrappers
- swagger-codegen for automated SDK generation
Decision Outcome
Chosen option: “openapi-generator as starting point, hand-tuned for ergonomics”, because it provides automated generation from the canonical OpenAPI spec while allowing idiomatic adjustments per language. The generation pipeline produces: Rust SDK (using reqwest as HTTP client), Python SDK (using httpx for async HTTP and Pydantic for models), and JavaScript/TypeScript SDK (using the native fetch API with full type definitions). Generated code is committed to the repository and treated as a starting point – maintainers may hand-tune method signatures, error handling, and documentation for better developer experience. Each SDK’s CI pipeline regenerates from the spec and flags any diff as a review item. swagger-codegen was rejected because openapi-generator is the actively maintained community fork with broader language support and more frequent updates. Hand-written SDKs were rejected because maintaining them across three languages as the spec evolves is unsustainable.
Consequences
- Good, because SDK generation from the spec ensures structural consistency across all languages
- Good, because hand-tuning allows idiomatic APIs (e.g., Python async context managers, Rust builder patterns)
- Good, because CI-based regeneration detects when SDKs drift from the spec
- Bad, because generated code can be verbose and may not follow each language’s best practices without tuning
- Bad, because openapi-generator’s output quality varies by language, with some targets requiring more hand-tuning than others
status: accepted date: 2026-03-12
ADR-0027: PostgreSQL Full-Text Search
Context and Problem Statement
The OpenTabletop API needs search functionality for finding games by name, description, designer, and other text fields. Search must support relevance ranking and ideally handle common misspellings. We need a search solution that balances capability with operational simplicity – adding a separate search service increases infrastructure complexity significantly.
Decision Drivers
- Search must support relevance-ranked results across multiple weighted fields
- Operational simplicity – minimizing the number of infrastructure components to operate
- The solution should work with the existing PostgreSQL database to avoid additional services
- Typo tolerance and fuzzy matching are desirable but not strictly required in v1
Considered Options
- Elasticsearch for full-featured search with fuzzy matching and faceting
- Meilisearch for typo-tolerant, easy-to-deploy search
- PostgreSQL native full-text search with tsvector
Decision Outcome
Chosen option: “PostgreSQL tsvector full-text search”, because it provides relevance-ranked search directly within the existing database without any additional infrastructure. A search_vector tsvector column is maintained via trigger, with weighted components: game name at weight A (highest), description at weight B, and designer names at weight C (lowest). The ts_rank function provides relevance scoring. A GIN index on the tsvector column ensures search queries are fast. This eliminates the need to synchronize data between the primary database and a separate search index. Meilisearch is documented in the deployment guide as an optional enhancement for operators who need typo tolerance and instant search – the API contract remains the same regardless of the search backend. Elasticsearch was rejected because its operational overhead (JVM, cluster management, index synchronization) is disproportionate to the project’s search requirements.
Consequences
- Good, because no additional infrastructure is required – search runs in the existing PostgreSQL instance
- Good, because weighted search ranking (name > description > designer) provides relevant results
- Good, because GIN-indexed tsvector queries are fast even on large datasets
- Bad, because PostgreSQL’s full-text search lacks typo tolerance and fuzzy matching out of the box
- Bad, because the search_vector column must be kept in sync via triggers, adding write-path complexity
status: accepted date: 2026-03-12
ADR-0029: Versioned Plain SQL Migration Files
Context and Problem Statement
The database schema must evolve over time as the API specification grows. Schema changes need to be versioned, reproducible, and applicable in a consistent order across all environments. The migration tooling choice also has implications for implementation portability – migrations tightly coupled to an ORM lock downstream implementations into that ORM’s ecosystem.
Decision Drivers
- Migrations must be tool-agnostic so that any implementation (Rust, Python, Go) can apply them
- Each migration must be versioned and applied in deterministic order
- Migrations must be reviewable as plain SQL in pull requests
- The migration format should not assume a specific ORM or framework
Considered Options
- ORM-generated migrations (e.g., Diesel migrations, Alembic, Django migrations)
- Plain SQL migration files with sequential numbering
- Schema-as-code (e.g., Atlas, Skeema) with declarative schema definitions
Decision Outcome
Chosen option: “Plain SQL migration files”, because SQL is the universal language understood by every database tool and every implementation language. Migration files follow the naming convention NNNN_description.sql (e.g., 0001_create_games_table.sql, 0002_add_player_count_polls.sql). Each file contains the forward migration SQL. The migration runner tracks applied migrations in a schema_migrations table. Any implementation can apply these migrations using its language’s database driver or a standalone tool like psql. ORM migrations were rejected because they lock the migration format to a specific ORM and language. Schema-as-code was rejected because declarative approaches can produce surprising migration plans for complex schema changes.
Consequences
- Good, because any implementation in any language can apply the same SQL migration files
- Good, because SQL migrations are directly reviewable in pull requests without ORM translation
- Good, because the
schema_migrationstracking table is a simple, universal pattern - Bad, because plain SQL migrations lack the automatic rollback generation that some ORMs provide
- Bad, because complex data migrations may require more verbose SQL than an ORM’s DSL would need
status: accepted date: 2026-03-12
ADR-0032: Strangler Fig Pattern for BGG Legacy Migration
Context and Problem Statement
Many applications currently rely on BoardGameGeek’s XML API or web scraping for board game data. Migrating these consumers to the OpenTabletop API must be incremental and low-risk – a “big bang” cutover would break existing integrations and risk data loss. We need a migration strategy that allows gradual, reversible adoption of the new API while maintaining compatibility with existing BGG-based workflows.
Decision Drivers
- Existing BGG-dependent applications must be able to migrate incrementally, not all-at-once
- BGG IDs must remain usable as lookup keys during and after migration
- The migration must be reversible at every step – no point of no return
- Data imported from BGG must be reconcilable with OpenTabletop’s native identifiers
Considered Options
- Big-bang migration – switch from BGG to OpenTabletop in a single cutover
- Parallel run – operate both systems indefinitely with manual synchronization
- Strangler fig pattern – incrementally route requests through an API gateway translation layer
Decision Outcome
Chosen option: “Strangler fig pattern with API gateway translation layer”, because it enables incremental, reversible migration from BGG data sources to the OpenTabletop API. The pattern works as follows: an API gateway sits in front of the consuming application, translating requests between BGG’s format and OpenTabletop’s format. Initially, all requests pass through to BGG. Over time, routes are individually switched to the OpenTabletop API as data coverage is confirmed. BGG IDs are stored as cross-references (per ADR-0008), enabling lookups by BGG ID through the OpenTabletop API. At any point, a route can be switched back to BGG if issues are discovered. Big-bang migration was rejected because it is high-risk and irreversible. Indefinite parallel run was rejected because maintaining synchronization between two data sources is operationally expensive and leads to data divergence.
Consequences
- Good, because migration is incremental – each route can be switched individually based on data readiness
- Good, because BGG ID cross-references enable seamless lookups during the transition period
- Good, because the migration is fully reversible at the route level – any route can fall back to BGG
- Bad, because the API gateway translation layer is additional infrastructure that must be built and maintained
- Bad, because data discrepancies between BGG and OpenTabletop may surface during the migration, requiring reconciliation
status: proposed date: 2026-03-15
ADR-0036: Time-Series Snapshots and Trend Analysis
Context and Problem Statement
The specification currently captures board game data as a static snapshot – what games exist, their properties, and their ratings right now. But the hobby is a living ecosystem with dramatic shifts over time: ranking eras (Twilight Imperium → Gloomhaven → Brass: Birmingham), mechanic waves (deck-building after Dominion, cooperative after Pandemic, legacy after Risk Legacy), publishing disruption (Kickstarter’s rise from 2012 onward), and demographic broadening. None of these dynamics are queryable today.
Two distinct types of trend analysis exist:
-
Cross-sectional trends – aggregating existing game data over
year_publishedto answer questions like “how many cooperative games were published per year?” or “what’s the average weight of games published each decade?” These require no new data model – only aggregate query endpoints over existing fields. -
Longitudinal trends – tracking the same entities over time to answer questions like “what was BGG #1 in 2019?” or “how did Gloomhaven’s rating change from 2018 to 2024?” These require periodic snapshots of game metrics – new data that does not exist in the current model.
The specification needs to support both types of trend analysis while keeping the data model manageable and storage costs proportional to value.
Decision Drivers
- Cross-sectional trends are derivable from existing data and should be available in v1.0 without new schemas
- Longitudinal trends require new data (periodic snapshots) and have significant storage implications
- The hobby’s evolution is a primary motivation for many data consumers (researchers, journalists, recommendation engines)
- Trend endpoints should compose with existing filter dimensions where applicable
- Storage costs for longitudinal snapshots grow linearly with dataset size × snapshot frequency
- The specification should define the contract without mandating a specific snapshot cadence
- Trend data ties naturally to the taxonomy’s phylogenetic model (mechanic origin games are industry inflection points)
Considered Options
- Cross-sectional only – Define aggregate endpoints over
year_publishedand defer all longitudinal analysis - Full snapshot model – Define both
GameSnapshot(per-game time series) andHobbySnapshot(aggregate time series) schemas with dedicated endpoints for both cross-sectional and longitudinal queries - Event-sourced model – Store every rating/vote/play as an immutable event and derive trends from the event stream
Decision Outcome
Chosen option: “Full snapshot model,” because it provides the most useful trend analysis with manageable complexity. Cross-sectional trends use existing data with new aggregate endpoints. Longitudinal trends use a new GameSnapshot schema that captures periodic metric snapshots at implementation-defined intervals.
The event-sourced model was considered but rejected – it provides maximum granularity at the cost of extreme storage requirements and query complexity. Periodic snapshots (monthly or quarterly) capture the trends that matter while keeping storage bounded.
Cross-sectional trend endpoints are specified for v1.0. Longitudinal snapshot storage and query endpoints are specified for v1.1, giving implementations time to build snapshot infrastructure.
Consequences
- Good, because cross-sectional trends are immediately available from existing data – no migration or new data collection needed
- Good, because longitudinal snapshots enable “rating over time,” “ranking history,” and “mechanic popularity wave” analysis
- Good, because the snapshot approach bounds storage costs – one record per game per snapshot period, not one per rating event
- Good, because trend endpoints compose with existing filter dimensions (filter by mechanic, category, year range, etc.)
- Good, because the specification defines the contract (schema, endpoints, response format) without mandating snapshot frequency, letting implementations choose monthly/quarterly/yearly based on their resources
- Bad, because longitudinal analysis requires implementations to collect and store snapshot data over time – trend quality improves with history length
- Bad, because snapshot granularity is a trade-off: monthly snapshots for 100k games = 1.2M rows/year; quarterly reduces this to 400k
- Bad, because historical snapshot data before an implementation starts collecting is not available unless backfilled from external sources
Rejected Options
Cross-sectional only was rejected because it cannot answer the most compelling trend questions – ranking trajectories, rating drift, expansion impact over time. These longitudinal questions are a primary use case for researchers and data journalists.
Event-sourced model was rejected because it requires storing every individual rating, vote, and play log as an immutable event. For a dataset with millions of ratings, this produces a write-heavy, storage-intensive system that is architecturally incompatible with the specification’s read-optimized design. Periodic snapshots capture 99% of the analytical value at 1% of the storage cost.
Implementation
New Schemas
GameSnapshot– A point-in-time capture of a single game’s metrics (rating, weight, rank, play count, owner count) at a specific date. One record per game per snapshot period.TrendDataPoint– An aggregate data point for cross-sectional trend queries, containing year/period, count, and statistical summaries (mean, median, percentiles).
Cross-Sectional Trend Endpoints (v1.0)
These aggregate over existing game data grouped by year_published:
GET /statistics/trends/publications– Games published per year/decade, filterable by mechanic/category/themeGET /statistics/trends/mechanics– Mechanic adoption over time (count and percentage per year)GET /statistics/trends/weight– Weight distribution over time (mean, median, percentiles), scoped to top-N, all, or published-that-yearGET /statistics/trends/player-count– Player count trends (average min/max, solo support percentage)
Longitudinal Endpoints (v1.1)
These query GameSnapshot data:
GET /games/{id}/history– Time series of a single game’s metrics (rating, weight, rank, play count)GET /statistics/rankings/history– Historical ranking snapshots (top N at a given date)GET /statistics/rankings/transitions– Games entering/exiting the top N over a date range
Game Schema Addition
An optional funding_source field on the Game entity enables crowdfunding trend analysis:
funding_source:
type: string
enum: [retail, kickstarter, gamefound, backerkit, self_published, other]
nullable: true
status: proposed date: 2026-03-15
ADR-0038: Alternate Names and Localization Support
Context and Problem Statement
The Game entity currently has a single name field and a sort_name field. Board games are published internationally under different names – Brass: Birmingham has alternate names in Russian (Brass. Бирмингем), Ukrainian (Brass. Бірмінгем), Japanese (ブラス:バーミンガム), Chinese (工业革命:伯明翰), and Korean (브라스: 버밍엄). Without alternate name storage, these names are invisible to search, making the API unusable for non-English discovery. BGG tracks alternate names per game; OpenTabletop must do the same to support international adoption and BGG migration (ADR-0032).
Decision Drivers
- International editions have localized names that users search by – a Japanese user searches “ブラス:バーミンガム”, not “Brass: Birmingham”
- Full-text search (ADR-0027) currently indexes only
nameandsort_name, missing all localized names - BGG migration requires importing alternate names to maintain search parity
- Game editions (ADR-0035) relate to localized names but serve a different purpose – edition names identify products, alternate names identify the game itself
- The change is small (one new schema, one new array field) with outsized impact on global usability
Considered Options
- Array of strings on Game – Simple
alternate_names: string[]with no metadata - Structured AlternateName schema with language and source – Each alternate name carries a BCP 47 language tag, primary flag, and source attribution
- Derive alternate names from editions – Use
GameEdition.nameas the source of localized names, no separate storage
Decision Outcome
Chosen option: “Structured AlternateName schema,” because language tags enable language-specific search indexing (e.g., Japanese tokenizer for Japanese names) and source attribution supports data provenance tracking. A flat string array loses the language signal that search depends on. Deriving from editions was rejected because alternate names and edition names serve different purposes – an edition name like “Brass: Birmingham – Czech edition 2025” is a product identifier, while the alternate name “Brass. Бірмінгем” is what users search for.
Consequences
- Good, because non-English users can discover games by their localized names
- Good, because full-text search can use language-appropriate tokenizers per alternate name
- Good, because BGG alternate names import directly into this schema
- Good, because source attribution tracks whether a name came from BGG, a publisher, or the community
- Bad, because alternate names may duplicate information already present in edition names – the boundary between the two should be documented clearly
- Bad, because deduplication across sources (same name from BGG and from a publisher submission) requires implementation-level logic
Implementation
New Schema
spec/schemas/AlternateName.yaml – A structured alternate name entry with language metadata.
Fields:
name(string, required) – The alternate namelanguage(string, nullable) – BCP 47 language tag (e.g., “ja”, “zh-Hans”, “ko”)is_primary(boolean, default false) – Whether this is the primary name in its languagesource(string, nullable) – Data source (“bgg”, “publisher”, “community”)
Game Schema Changes
Add alternate_names array to Game.yaml, included via ?include=alternate_names.
Include Parameter
Add alternate_names to the list of available include values in spec/parameters/include.yaml.
Search Integration
Alternate names should be indexed for full-text search (ADR-0027) with language-appropriate analyzers where available.
BGG Migration Mapping
BGG <name type="alternate"> elements map directly to AlternateName entries. The BGG type="primary" name maps to Game.name; all type="alternate" names become AlternateName entries with source: "bgg". Language tags must be inferred from the edition language or detected algorithmically – BGG does not store language metadata on alternate names.
status: proposed date: 2026-03-15
ADR-0039: Extended Game Credits with Role Taxonomy
Context and Problem Statement
The specification currently models three credit types as separate schemas: Designer, Artist, and Publisher. BGG tracks 10+ credit roles including developer, graphic designer, sculptor, editor, writer, insert designer, solo mode designer, and narrator. Real-world game credits are diverse – Brass: Birmingham credits designers (Gavan Brown, Matt Tolman, Martin Wallace), artists (6 people), and 22 publishers, plus has fields for solo designer, developer, graphic designer, sculptor, editor, writer, and insert designer. Without extended credit roles, the specification cannot represent complete game credits, blocking both publisher adoption (attribution is non-negotiable) and BGG migration (ADR-0032).
Decision Drivers
- Publishers and designers consider credit accuracy non-negotiable for data submission
- BGG tracks 10+ credit roles; our 3 schemas cover only ~30% of real credit data
- Adding a separate schema per role (Developer.yaml, GraphicDesigner.yaml, etc.) does not scale – every new role requires a spec change, new endpoints, new include values
- The existing Designer, Artist, and Publisher schemas are well-established and should not be broken
- A single person may hold multiple roles (e.g., Gavan Brown is both designer and artist on Brass: Birmingham)
Considered Options
- Separate schema per role – Add Developer.yaml, GraphicDesigner.yaml, Sculptor.yaml, Editor.yaml, Writer.yaml, InsertDesigner.yaml, etc.
- Unified Person schema replacing Designer/Artist – Replace all people schemas with a single Person entity and role-based join table
- Keep Designer/Artist, add Person + GameCredit for additional roles – Preserve backward compatibility while extending credit coverage
Decision Outcome
Chosen option: “Keep Designer/Artist, add Person + GameCredit,” because it preserves backward compatibility with existing /designers and /artists endpoints while supporting all additional credit roles. Designer and Artist (the two most common roles, covering ~95% of credits) keep their dedicated schemas, endpoints, and include values. Publisher keeps its distinct schema (it has unique fields: country, website). All additional roles go through a GameCredit join table with a role enum.
The unified replacement was rejected because it would break existing endpoints and lose the ergonomic typed fields (Designer has description, Artist does not, Publisher has country/website). Separate schemas per role were rejected because they create 8+ new schemas, endpoints, and include values, and don’t scale when new roles emerge.
Consequences
- Good, because existing Designer, Artist, and Publisher endpoints remain unchanged – zero breaking changes
- Good, because all BGG credit roles can be represented
- Good, because a single person appearing in multiple roles (designer + artist) is naturally supported via the Person entity
- Good, because new roles can be added to the enum via the RFC process without schema restructuring
- Bad, because credits are split across two mechanisms (dedicated schemas for designer/artist, GameCredit for others) – the
/games/{id}/creditsendpoint must unify them - Bad, because Person deduplication across BGG import requires fuzzy name matching
Implementation
New Schemas
spec/schemas/Person.yaml – Lightweight unified person entity. Designer and Artist become role-filtered views over Person.
Fields:
id(UUID, required)slug(string, required)name(string, required)description(string, nullable)game_count(integer)_links(Links)
spec/schemas/GameCredit.yaml – Join model linking a person to a game with a specific role.
Fields:
game_id(UUID, required)person_id(UUID, required)role(enum, required):developer,graphic_designer,sculptor,editor,writer,insert_designer,solo_mode_designer,narrator,producerperson_name(string) – denormalized for convenienceperson_slug(string) – denormalized for link construction_links(Links)
New Endpoint
GET /games/{id}/credits – Returns all credits for a game, unifying designers, artists, and additional GameCredit roles into a single response. Tagged under “People”.
Game Schema Changes
Add credits array to Game.yaml (GameCredit items), included via ?include=credits.
Include Parameter
Add credits to the include parameter in spec/parameters/include.yaml.
BGG Migration Mapping
| BGG Credit Role | OpenTabletop Mapping |
|---|---|
| boardgamedesigner | Designer schema (existing) |
| boardgameartist | Artist schema (existing) |
| boardgamepublisher | Publisher schema (existing) |
| Solo Designer | GameCredit with role solo_mode_designer |
| Developer | GameCredit with role developer |
| Graphic Designer | GameCredit with role graphic_designer |
| Sculptor / 3D Sculptor | GameCredit with role sculptor |
| Editor | GameCredit with role editor |
| Writer | GameCredit with role writer |
| Insert Designer | GameCredit with role insert_designer |
status: proposed date: 2026-03-15
ADR-0040: Edition Product and Physical Metadata
Context and Problem Statement
The GameEdition schema (ADR-0035) currently tracks only basic metadata: name, year, publisher (singular), language, notes, and property deltas. Real-world editions carry significantly more data. Brass: Birmingham has 31 editions on BGG, each with product codes (UPC/EAN/SKU), physical dimensions, box weight, specific release dates, release status, per-edition box art, and per-edition artist credits. Some editions have multiple co-publishers (e.g., “Roxley, TLAMA games” for the Czech edition).
Without this data, the specification cannot serve publishers (who need product codes for retail distribution, dimensions for shipping, and release dates for marketing) or consumers (who need to identify which physical product they own). The singular publisher_id field is also a data fidelity issue – co-published editions are common in international markets.
Decision Drivers
- Publishers need product codes (UPC/EAN) for retail distribution and inventory management
- Physical dimensions and box weight are essential for shipping cost calculation and shelf planning
- Release dates (full ISO dates, not just year) are needed for marketing calendars and pre-order tracking
- Per-edition box art enables product identification – different editions often have different cover art
- Multiple publishers per edition is common (co-publishing agreements for international editions)
- BGG tracks all of these fields per version; migration fidelity requires them
- The specification is pre-v1.0, so breaking changes to GameEdition are acceptable
Considered Options
- Extend GameEdition in place – Add new fields directly to the existing schema
- Create a separate EditionProduct schema – Split product metadata into its own schema linked to GameEdition
- Leave edition metadata minimal – Keep editions focused on property deltas; product data is implementation-specific
Decision Outcome
Chosen option: “Extend GameEdition in place,” because all these fields are intrinsic properties of a published edition – they describe what the physical product is. Splitting them into a separate schema would create an artificial boundary that complicates both the API (two entities to fetch per edition) and the data model (a 1:1 relationship masquerading as separate entities). Leaving editions minimal was rejected because publishers and consumers need this data, and BGG migration requires it.
The publisher_id singular field is replaced with publisher_ids array. Since the spec is at version 0.1.0 with no conforming implementations, this breaking change is acceptable per ADR-0005.
Consequences
- Good, because editions fully describe the physical product – enough data for a publisher to identify, ship, and market
- Good, because product codes enable barcode scanning and retail system integration
- Good, because per-edition images allow consumers to visually identify their specific edition
- Good, because multiple publishers per edition accurately represents co-publishing
- Good, because release status enables pre-order tracking and out-of-print detection
- Bad, because GameEdition becomes a larger schema (from 8 to 17 properties) – but editions are inherently data-rich
- Bad, because
publisher_idtopublisher_idsis a breaking change – acceptable pre-v1.0
Implementation
GameEdition Schema Changes
Replace publisher_id (singular UUID) with:
publisher_ids(UUID[], required, minItems: 1) – Multiple publishers for co-published editions
Add new fields:
product_codes(ProductCode[]) – Array of typed product identifiersdimensions(PhysicalDimensions) – Box dimensions with unitbox_weight(PhysicalWeight) – Box weight with unit (distinct from complexity weight)release_status(enum: announced, preorder, released, out_of_print)release_date(ISO 8601 date) – Full date, more precise than year_publishedimage_url(URI) – Per-edition box artthumbnail_url(URI) – Per-edition thumbnailartist_ids(UUID[]) – Per-edition artist credits
New Sub-Schemas
ProductCode – Typed product identifier:
type(enum: upc, ean, isbn, sku, asin)code(string)
PhysicalDimensions – Box dimensions:
length,width,height(float)unit(enum: cm, in, default: cm)
PhysicalWeight – Box weight:
value(float)unit(enum: kg, lb, default: kg)
BGG Migration Mapping
| BGG Version Field | OpenTabletop Edition Field |
|---|---|
| item name | name |
| yearpublished | year_published |
| publisher link | publisher_ids (array) |
| language | language (BCP 47) |
| productcode | product_codes (type: sku) |
| width × length × depth | dimensions |
| weight | box_weight |
| image | image_url |
| thumbnail | thumbnail_url |
| N/A (inferred) | release_status |
| releasedate (if available) | release_date |
status: proposed date: 2026-03-15
ADR-0041: Community Signals and Aggregate Statistics
Context and Problem Statement
The Game entity lacks several categories of community-generated data that BGG tracks and that publishers and designers rely on for decision-making:
-
Rating distribution – BGG shows a 1-10 histogram (e.g., Brass: Birmingham: 379/156/264/336/697/1,820/4,879/12,806/19,465/16,529). We store only
average_ratingandrating_count, losing the distribution shape. A bimodal distribution (love-it-or-hate-it) looks identical to a normal distribution at the same average – publishers need the shape for marketing strategy. -
Rankings – BGG shows overall rank (#1) and category-specific ranks (Strategy #1). We store these in
GameSnapshotfor historical trends but not on the Game entity for current state. -
Collection signals – BGG tracks owners (82,296), wishlists (21,679), trades, and previous owners. These are demand gauges that publishers use for print run planning.
-
Play activity – BGG tracks all-time plays (166,001) and monthly plays. The plays-per-owner ratio measures engagement vs “shelf of shame” – valuable for designers evaluating replayability.
-
Community polls – Beyond player count polls (which we have), BGG runs suggested age polls and language dependence polls. These help publishers validate age ratings and estimate localization effort.
Decision Drivers
- Rating distribution shape is a distinct analytical signal from the average – bimodal distributions require different marketing than tight normals
- Rankings are the most-requested data point for casual consumers (“what’s #1?”)
- Collection signals (owner/wishlist counts) are direct demand metrics for publishers planning print runs
- Language dependence is critical for publishers evaluating localization ROI
- Community suggested age validates or contradicts publisher-stated
min_age GameSnapshotalready capturesrank_overall,rank_by_category, andowners_countat snapshot time – promoting these to live Game fields avoids requiring snapshot queries for current state- Total plays and engagement metrics help designers evaluate whether their game has replayability
Considered Options
- Live fields on Game entity – Add all aggregate statistics directly to the Game schema
- Separate Statistics sub-resource – Create a
/games/{id}/statsendpoint with its own schema - Snapshot-only – Keep all aggregate data in
GameSnapshot; query the latest snapshot for current values
Decision Outcome
Chosen option: “Live fields on Game entity,” because these are fundamental game attributes that consumers expect on the primary resource. Requiring a separate request or snapshot query for “what rank is this game?” creates unnecessary friction. The fields are periodically updated (not real-time), consistent with how weight and average_rating already work on the Game entity.
A separate statistics sub-resource was considered but rejected because it splits core game data across two endpoints. Snapshot-only was rejected because it requires consumers to understand the snapshot system just to get the current rank – an unnecessary abstraction leak.
Consequences
- Good, because the Game entity becomes a comprehensive representation – rank, distribution, collection signals, and engagement are all available in a single request
- Good, because field naming aligns with
GameSnapshot(same names, same semantics) –GameSnapshotcaptures history, Game shows current state - Good, because publishers and designers get actionable metrics without understanding the snapshot system
- Bad, because the Game entity grows from ~29 to ~40 properties – implementations may want to distinguish “summary” vs “detail” field sets
- Bad, because aggregate statistics are stale the moment they’re computed – the spec should document refresh expectations
- Bad, because
GameSnapshot.owners_countmust be renamed toowner_countfor consistency with the Game entity convention
Implementation
Game Schema Changes
Add these fields to spec/schemas/Game.yaml:
Rating distribution:
rating_distribution(integer[10]) – Histogram of votes per rating bucket (index 0 = count of 1-star, index 9 = count of 10-star)rating_stddev(float) – Standard deviation of the rating distribution
Rankings:
rank_overall(integer, nullable) – Current overall ranking positionrank_by_category(map of category slug → rank, nullable) – Per-category rankings
Collection signals:
owner_count(integer, nullable) – Users who own this gamewishlist_count(integer, nullable) – Users who have wishlisted this game
Play activity:
total_plays(integer, nullable) – All-time logged play count
Community polls:
community_suggested_age(integer, nullable) – Community-polled minimum agelanguage_dependence(enum, nullable) –no_text,some_text,moderate_text,extensive_text,unplayable_without_text
New Poll Schemas
Following the PlayerCountPoll pattern:
spec/schemas/CommunityAgePoll.yaml – Per-age vote counts:
game_id(UUID, required)suggested_age(integer, required)vote_count(integer, required)
spec/schemas/LanguageDependencePoll.yaml – Per-level vote counts:
game_id(UUID, required)level(enum, required):no_text,some_text,moderate_text,extensive_text,unplayable_without_textvote_count(integer, required)
New Endpoint
GET /games/{id}/polls – Returns all community poll data (player count, age, language dependence). Add polls to the include parameter.
GameSnapshot Alignment
Rename GameSnapshot.owners_count to owner_count for consistency with rating_count, weight_votes, and the new Game entity field.
BGG Migration Mapping
| BGG Field | OpenTabletop Field |
|---|---|
| Rating histogram (1-10) | rating_distribution |
| Std. Deviation | rating_stddev |
| Overall Rank | rank_overall |
| Category-specific Rank | rank_by_category |
| Owned | owner_count |
| Wishlist | wishlist_count |
| All Time Plays | total_plays |
| Suggested Player Age poll | CommunityAgePoll + community_suggested_age |
| Language Dependence poll | LanguageDependencePoll + language_dependence |
status: proposed date: 2026-03-15
ADR-0042: Game Awards and Recognition
Context and Problem Statement
Board game awards are a significant factor in purchasing decisions, marketing materials, and game discovery. Awards like the Spiel des Jahres, Kennerspiel des Jahres, Dice Tower Awards, Golden Geek, and International Gamers Award are referenced constantly by publishers, reviewers, and consumers. BGG tracks award nominations and wins per game. The specification currently has no award model, leaving a data gap that matters to every stakeholder: publishers use awards in marketing, designers use them as career milestones, and consumers use them for discovery (“show me all Spiel des Jahres winners”).
Decision Drivers
- Awards are a primary discovery mechanism – “Spiel des Jahres winner” is the most recognized quality signal in the hobby
- Publishers prominently display awards on box art and marketing materials; they need this data to be queryable
- Filtering by award status (“all Kennerspiel winners”) is a high-value search use case
- Awards have a stable, well-understood data model: an award, a year, a game, and a result (nominated/won)
- The data is purely additive – no existing schemas need modification beyond adding an optional include array
Considered Options
- Dedicated Award and GameAward schemas – Full entity modeling with endpoints for awards and per-game award listings
- Tags/labels on Game entity – Simple string array of award labels (e.g., [“spiel-des-jahres-2020-nominee”])
- Defer to v2 – Awards are metadata, not structural; implement later
Decision Outcome
Chosen option: “Dedicated Award and GameAward schemas,” because awards are a distinct domain with their own lifecycle (new awards are created, results are announced annually) and query patterns (filter by award, list all winners of an award, see a game’s awards). A tag/label approach loses the structured year and result data needed for temporal queries (“who won in 2020?”) and loses the ability to list all nominees for a given award year. Deferral was rejected because awards are a top-3 publisher and consumer expectation for a complete game database.
Consequences
- Good, because awards become a first-class queryable dimension – “show me all Spiel des Jahres winners from 2015-2025”
- Good, because the data model naturally represents the award lifecycle (nomination → shortlist → win)
- Good, because publishers can reference award status in their data submissions
- Good, because award filtering composes with existing filter dimensions (all Kennerspiel winners that play well at 2)
- Bad, because the initial award taxonomy must be seeded – there are dozens of recognized board game awards worldwide
- Bad, because award categorization is complex – the Spiel des Jahres family has three sub-awards (SdJ, Kennerspiel, Kinderspiel), the Dice Tower has multiple categories, etc.
Implementation
New Schemas
spec/schemas/Award.yaml – An award or award family:
id(UUID, required)slug(string, required) – e.g., “spiel-des-jahres”name(string, required) – e.g., “Spiel des Jahres”description(string)organization(string) – e.g., “Verein Spiel des Jahres”website(URI)_links(Links)
spec/schemas/GameAward.yaml – A game’s relationship to an award:
game_id(UUID, required)award_id(UUID, required)year(integer, required) – The award cycle yearresult(enum, required):nominated,shortlisted,recommended,woncategory(string, nullable) – Specific track within the award family (e.g., “Kennerspiel des Jahres”)_links(Links)
New Endpoints
GET /awards– List all awards (paginated)GET /awards/{id}– Single award detailGET /games/{id}/awards– Awards for a specific game
Game Schema Changes
Add awards array to Game.yaml (GameAward items), included via ?include=awards.
Include Parameter
Add awards to the include parameter in spec/parameters/include.yaml.
Search Integration
Add optional award filter to SearchFilters for filtering games by award slug and/or result (e.g., award.slug=spiel-des-jahres&award.result=won).
BGG Migration Mapping
BGG stores awards as family links with type “boardgamehonor”. Each honor entry maps to a GameAward record. The award family (e.g., “Spiel des Jahres”) maps to an Award entity. The specific honor text must be parsed to extract year, result (nominee vs winner), and category.
Getting Started
There are two ways to use OpenTabletop:
- Building a server or app? Start with the Implementer’s Guide – it walks you through database setup, loading sample data, and implementing endpoints step by step.
- Using an existing API? Read on. This guide shows you how to query any OpenTabletop-conforming server.
The examples below use {your-server} as a placeholder – substitute the base URL of whichever conforming server you are working with. Every conforming server exposes the same endpoints and accepts the same parameters.
Base URL
Point your requests at the /v1 root of your chosen server:
https://{your-server}/v1
The specification requires that conforming implementations provide unauthenticated read-only access with rate limiting (default: 60 requests/minute by IP). See ADR-0016.
Your First Request
Fetch a game by its slug:
curl https://{your-server}/v1/games/spirit-island
Response (abbreviated – full response includes all fields):
{
"id": "01912f4c-7e3a-7b1a-8c5d-9f0e1a2b3c4d",
"slug": "spirit-island",
"name": "Spirit Island",
"type": "base_game",
"year_published": 2017,
"min_players": 1,
"max_players": 4,
"min_playtime": 90,
"max_playtime": 120,
"community_min_playtime": 75,
"community_max_playtime": 150,
"min_age": 13,
"community_suggested_age": 14,
"weight": 3.86,
"rating": 8.22,
"rating_votes": 42891,
"rating_confidence": 0.45,
"mode": "cooperative",
"top_player_counts": [1, 2, 3],
"recommended_player_counts": [1, 2, 3, 4],
"_links": {
"self": { "href": "/v1/games/spirit-island" },
"expansions": { "href": "/v1/games/spirit-island/expansions" },
"effective_properties": { "href": "/v1/games/spirit-island/effective-properties" },
"player_count_ratings": { "href": "/v1/games/spirit-island/player-count-ratings" },
"relationships": { "href": "/v1/games/spirit-island/relationships" },
"experience_playtime": { "href": "/v1/games/spirit-island/experience-playtime" }
}
}
Filtering Games
The real power is in compound filtering. Find cooperative games for 4 players, under 90 minutes, medium complexity:
curl "https://{your-server}/v1/games? \
players=4& \
community_playtime_max=90& \
weight_min=2.0&weight_max=3.5& \
mode=cooperative& \
sort=bayes_rating&order=desc& \
limit=10"
Response:
{
"data": [
{
"slug": "pandemic",
"name": "Pandemic",
"type": "base_game",
"min_players": 2,
"max_players": 4,
"weight": 2.42,
"rating": 7.6,
"mode": "cooperative",
"matched_via": null,
"_links": { "self": { "href": "/v1/games/pandemic" } }
}
],
"meta": {
"total": 1,
"next_cursor": "MDFhYmMx..."
},
"_links": {
"self": { "href": "/v1/games" },
"next": { "href": "/v1/games?cursor=MDFhYmMx...&limit=10" }
}
}
Every game in the response satisfies all filter dimensions simultaneously (cross-dimension AND). The matched_via field is null unless effective=true is set.
Searching in Other Languages
Games carry alternate names in any language. Search works across all of them:
# Search by Japanese name
curl "https://{your-server}/v1/search?q=ブラス:バーミンガム"
# Search by Korean name
curl "https://{your-server}/v1/search?q=브라스:%20버밍엄"
Both return the same Brass: Birmingham entity. The full-text search indexes all alternate names with language-appropriate tokenizers, so users can discover games in their own language regardless of what language the game was originally published in.
Expansion-Aware Filtering
Add effective=true to filter against properties that include expansion modifications:
# Games that support 6 players with at least one expansion
curl "https://{your-server}/v1/games? \
players=6& \
effective=true& \
type=base_game"
Response – Spirit Island base game supports only 1-4, but matches because Branch & Claw + Jagged Earth expand it to 6:
{
"data": [
{
"slug": "spirit-island",
"name": "Spirit Island",
"type": "base_game",
"min_players": 1,
"max_players": 4,
"matched_via": {
"type": "expansion_combination",
"expansions": [
{ "slug": "spirit-island-branch-and-claw", "name": "Spirit Island: Branch & Claw" },
{ "slug": "spirit-island-jagged-earth", "name": "Spirit Island: Jagged Earth" }
],
"effective_properties": {
"min_players": 1,
"max_players": 6,
"weight": 4.07,
"min_playtime": 105,
"max_playtime": 180
},
"resolution_tier": 1
}
}
],
"meta": { "total": 1 },
"_links": { "self": { "href": "/v1/games" } }
}
The resolution_tier tells you how the match was derived: 1 = explicit community-curated combination, 2 = computed from individual expansion deltas, 3 = base game properties only.
Effective Properties
See how a game’s properties change with specific expansions:
curl "https://{your-server}/v1/games/spirit-island/ \
effective-properties?with=branch-and-claw,jagged-earth"
Response:
{
"base": {
"min_players": 1,
"max_players": 4,
"min_playtime": 90,
"max_playtime": 120,
"weight": 3.86,
"min_age": 13
},
"applied_expansions": ["branch-and-claw", "jagged-earth"],
"effective": {
"min_players": 1,
"max_players": 6,
"min_playtime": 105,
"max_playtime": 180,
"weight": 4.07,
"min_age": 13
},
"combination_source": "explicit"
}
The combination_source indicates which tier of the three-tier resolution model was used: "explicit" (community-curated combination record), "computed" (sum of individual expansion deltas), or "base_only" (no expansion data available).
Complex Queries
For queries too complex for URL parameters, use POST /v1/games/search:
curl -X POST https://{your-server}/v1/games/search \
-H "Content-Type: application/json" \
-d '{
"players": 4,
"effective": true,
"playtime_max": 90,
"playtime_source": "community",
"weight_min": 2.0,
"weight_max": 3.5,
"mode": "cooperative",
"mechanics": ["hand-management", "area-control"],
"theme_not": ["space"],
"sort": "bayes_rating",
"order": "desc",
"limit": 25
}'
Response:
{
"data": [
{
"slug": "pandemic",
"name": "Pandemic",
"type": "base_game",
"weight": 2.42,
"rating": 7.6,
"mode": "cooperative",
"matched_via": null
}
],
"meta": {
"total": 1,
"next_cursor": "MDFhYmMx..."
},
"_links": {
"self": { "href": "/v1/games/search" },
"next": { "href": "/v1/games/search?cursor=MDFhYmMx...&limit=25" }
}
}
The compound search supports all 9 filter dimensions – see the Implementing Guide for the full list. Filters compose with AND across dimensions and OR within dimensions.
Authentication
The specification defines tiered API key authentication (ADR-0016). For higher rate limits (600/min) or write access, use an API key:
curl -H "X-API-Key: your-api-key" \
https://{your-server}/v1/games
Pagination
All list endpoints use cursor-based pagination (ADR-0012):
{
"data": [ "..." ],
"meta": {
"total": 142,
"next_cursor": "MDFhYmMx...",
"prev_cursor": "MDFhYmMy..."
},
"_links": {
"self": { "href": "/v1/games" },
"next": { "href": "/v1/games?cursor=MDFhYmMx...&limit=25" },
"prev": { "href": "/v1/games?before=MDFhYmMy...&limit=25" }
}
}
Use the next_cursor value to fetch the next page:
curl "https://{your-server}/v1/games?cursor=MDFhYmMx...&limit=25"
Use before with prev_cursor to go back:
curl "https://{your-server}/v1/games?before=MDFhYmMy...&limit=25"
Exploring the Spec
Want to see every endpoint and schema interactively? Bundle the spec and preview it:
./scripts/bundle-spec.sh
npx @redocly/cli preview-docs spec/bundled/openapi.yaml
This opens a browsable API reference in your browser – useful for understanding the full data model before building against it.
Next Steps
- Building a server? Implementer’s Guide – Database schema, sample data loader, endpoint walkthrough
- Filter Dimensions – Full reference for all filtering dimensions
- Expansion Model – How combinatorial expansion effects work
- Data Export – Bulk data access for analysis
Implementing the Spec
OpenTabletop is a specification, not a product. This guide walks you through building a conforming server – from exploring the spec to loading data to implementing the hard parts. It is written as a team playbook: follow the steps in order and you will end up with a fully-fledged, conforming API.
Step 1: Explore the Specification
The OpenAPI 3.1 specification at spec/openapi.yaml is the source of truth. Start by browsing it interactively:
# Bundle the multi-file spec into a single file
./scripts/bundle-spec.sh
# Preview in Swagger UI (any static server works)
npx @redocly/cli preview-docs spec/bundled/openapi.yaml
This gives you a browsable view of every endpoint, schema, and parameter. Spend time here before writing code – the spec is dense and the filtering model is unusually rich.
Key sections to read first:
- The
Gameschema – the core entity with type discriminator, dual playtime, and community signals - The
SearchRequestschema – the compound filtering model (this is where OpenTabletop differs most from other APIs) - The
PlayerCountRatingschema – per-count numeric ratings, not just min/max - The
ExpansionCombinationandPropertyModificationschemas – the three-tier expansion resolution model
Step 2: Design Your Database
The specification is format-agnostic, but most implementations will use a relational database. A recommended PostgreSQL schema is provided at data/samples/schema.sql.
The schema covers:
erDiagram
games ||--o{ game_mechanics : has
games ||--o{ game_categories : has
games ||--o{ game_themes : has
games ||--o{ game_relationships : source
games ||--o{ player_count_ratings : has
games ||--o{ experience_playtime : has
games ||--o{ expansion_combinations : base
games ||--o{ property_modifications : base
games ||--o{ game_credits : has
games ||--o{ game_editions : has
games ||--o{ game_awards : has
games ||--o{ game_snapshots : has
games ||--o{ external_identifiers : has
games ||--o{ alternate_names : has
games }o--o| games : parent
mechanics ||--o{ game_mechanics : tagged
categories ||--o{ game_categories : tagged
themes ||--o{ game_themes : tagged
people ||--o{ game_credits : credited
publishers ||--o{ game_editions : publishes
awards ||--o{ game_awards : given
To create the schema:
createdb opentabletop
psql opentabletop < data/samples/schema.sql
Key design decisions in the schema:
- UUIDv7 primary keys – Time-ordered for index locality (ADR-0008)
- Slugs as unique secondary keys – Immutable, URL-safe, used for API lookups
- Junction tables for taxonomy –
game_mechanics,game_categories,game_themesenable the AND/OR/NOT filtering - Separate
player_count_ratingstable – One row per (game, player_count) pair, not embedded JSON expansion_combinationstable – Explicit tier-1 records for the three-tier resolution model- Generated
tsvectorcolumn – Full-text search across name and description (ADR-0027)
As your schema evolves, use versioned SQL migration files following ADR-0029. The initial schema is the starting point; you will add raw vote tables, expansion delta seed data, and other tables as your implementation grows. A typical migration directory looks like:
migrations/
0001_raw_vote_tables.sql
0002_seed_expansion_deltas.sql
0003_add_game_snapshots.sql
Use any SQL-native migration runner (golang-migrate, Flyway, dbmate, or plain psql). Never use ORM-generated migrations – the schema is the spec’s recommended design and should be maintained as explicit SQL.
Data Model Checklist: Database
- Schema matches ER diagram – all entities and join tables present
- UUIDv7 primary keys, slug unique secondary keys
- Entity types supported: base_game, expansion, standalone_expansion, promo, accessory, fan_expansion
- Dual playtime columns: both publisher-stated and community-reported
- Rating columns: rating, rating_votes, rating_distribution, rating_stddev, rating_confidence
- Weight columns: weight + weight_votes
- Taxonomy join tables: game_mechanics, game_categories, game_themes
- Relationships table: typed directed edges with ordinal
- Full-text search vector column with GIN index
Step 3: Set Up Local Development
Before writing any application code, get a local development stack running. A docker-compose.yml with PostgreSQL gives you a reproducible environment that mirrors production:
# docker-compose.yml
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_DB: opentabletop
POSTGRES_USER: ot
POSTGRES_PASSWORD: localdev
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
- ./data/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql
- ./data/seed.sql:/docker-entrypoint-initdb.d/02-seed.sql
volumes:
pgdata:
The key detail is the docker-entrypoint-initdb.d volume mount: PostgreSQL automatically runs SQL files in that directory on first startup, so your schema is loaded without a manual step.
Create a .env file for your application (12-factor config from environment, per ADR-0020):
DATABASE_URL=postgresql://ot:localdev@localhost:5432/opentabletop
PORT=8080
LOG_LEVEL=debug
Start the stack and verify the database is ready:
docker compose up -d
psql "$DATABASE_URL" -c "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public';"
Reference the Deploying Guide for production setup including Kubernetes manifests, observability, TLS, and capacity planning.
Data Model Checklist: Local Development
-
docker-compose.ymlstarts PostgreSQL with schema auto-loaded viainitdb.d -
.envfile providesDATABASE_URL,PORT,LOG_LEVEL(ADR-0020) - Application reads all config from environment variables, never hardcoded
- Database connection pooling configured (10-20 connections per process)
Step 4: Load Sample Data
The data/samples/ directory contains demonstration records for Spirit Island and Terraforming Mars. A loader script is provided:
# From the spec repository root (where package.json lives):
npm install
# Load into your database
node scripts/load-samples.js --connection "postgresql://localhost/opentabletop"
# Or dry-run to see the SQL without executing
node scripts/load-samples.js --dry-run
The loader reads the YAML files, maps them to the schema from Step 2, and inserts the records. It also loads the controlled vocabularies from data/taxonomy/ (mechanics, categories, themes).
After loading, verify the data:
SELECT name, type, weight, rating FROM games;
SELECT g.name, pcr.player_count, pcr.average_rating
FROM player_count_ratings pcr
JOIN games g ON g.id = pcr.game_id
ORDER BY g.name, pcr.player_count;
Data Model Checklist: Sample Data
-
Controlled vocabulary loaded: mechanics, categories, themes from
data/taxonomy/ - Sample games loaded with all required fields (id, slug, name, type)
- Player count ratings seeded for sample games
- Experience playtime data present for sample games
- Expansion combinations seeded for sample expansion families
Step 5: Implement Core Endpoints
Start with the read-only endpoints that form the backbone of the API:
-
GET /v1/games– List games with query parameter filtering. Supportplayers,weight_min,weight_max,type,mode,sort, andeffectiveas query parameters. Use keyset (cursor-based) pagination, not offset-based (ADR-0012). -
GET /v1/games/{id}– Single game by UUID or slug. Return the fullGameschema. Support?include=expansionsfor embedding related resources (ADR-0017). Include full_linkswith self, expansions, effective_properties, player_count_ratings, relationships, and experience_playtime. -
GET /v1/games/{id}/expansions– List expansions for a base game. Filtergameswhereparent_game_idmatches. -
GET /v1/mechanics,GET /v1/categories,GET /v1/themes– List taxonomy terms. Return canonical slugs and display names. These endpoints power the controlled vocabulary that prevents tag proliferation (ADR-0009). -
GET /v1/search?q=...– Full-text search via PostgreSQLtsvector. Weighted across name (highest), short description, and full description. See ADR-0027. -
GET /healthzandGET /readyz– Liveness (“process is alive”) and readiness (“can serve traffic, database connected”) probes. These are required for Kubernetes orchestration and should be implemented early.
All list endpoints should return paginated responses with _links for navigation (ADR-0018) and support the ?include parameter for embedding related resources (ADR-0017).
Data Model Checklist: Core Endpoints
- Game entity returns all required fields (id, slug, name, type) plus optional fields
- Identifier lookup: both UUID and slug resolve to the same entity
- Taxonomy endpoints: mechanics, categories, themes return canonical slugs
- Relationships: typed edges queryable (expands, reimplements, integrates_with)
- Age fields: min_age (publisher) + community_suggested_age
-
_linksinclude self, expansions, effective_properties, player_count_ratings, relationships - 404 returns RFC 9457 Problem Details format
Step 6: Implement Player Count & Experience Playtime
These two sub-resources expose the per-count and per-experience-level data that makes OpenTabletop’s filtering model possible.
Player Count Ratings
GET /v1/games/{id}/player-count-ratings – Returns per-player-count community ratings. Each row contains a player count, average rating (1-5 scale), vote count, and standard deviation.
Player count is not just a range; it is a distribution of quality across the supported range. A game that “supports 1-5 players” may be excellent at 3, good at 2, and actively bad at 5. The per-count ratings capture this nuance.
Example response for Terraforming Mars:
{
"data": [
{ "player_count": 1, "average_rating": 2.1, "rating_count": 1277, "rating_stddev": 1.2 },
{ "player_count": 2, "average_rating": 4.2, "rating_count": 1222, "rating_stddev": 0.7 },
{ "player_count": 3, "average_rating": 4.7, "rating_count": 1407, "rating_stddev": 0.5 },
{ "player_count": 4, "average_rating": 3.6, "rating_count": 1246, "rating_stddev": 1.0 },
{ "player_count": 5, "average_rating": 2.3, "rating_count": 1141, "rating_stddev": 1.1 }
]
}
From this data, consumers can derive top_player_counts (counts rated above 4.0) and recommended_player_counts (counts rated above 3.0). The specification stores raw data, not derived labels – different applications may use different thresholds.
Experience Playtime
GET /v1/games/{id}/experience-playtime – Returns play time estimates bucketed by experience level. Four levels with per-game multipliers derived from community play logs:
| Level | Description | Typical Multiplier |
|---|---|---|
first_play | Everyone is new to the game | ~1.5x |
learning | 1-3 prior plays, still referencing rules | ~1.25x |
experienced | 4+ plays, knows the rules well (baseline) | 1.0x |
expert | Optimized play, minimal downtime | ~0.85x |
Different games have fundamentally different experience curves: a party game has near-zero first-play penalty while a heavy euro may take 2x longer on first play. Per-game multipliers from community play logs capture these differences accurately.
See Player Count Model and Play Time Model for the full specification.
Data Model Checklist: Player Count & Experience Playtime
- Player count ratings: per-count 1-5 numeric scale (not legacy three-tier)
- Ratings include average_rating, rating_count, and rating_stddev per count
- Experience-bucketed playtime: four levels (first_play, learning, experienced, expert)
- Per-game multipliers derived from play logs, not global defaults
-
sufficient_dataflag indicates whether game-specific or global multipliers are in use -
Derived fields on Game entity:
top_player_counts,recommended_player_counts
Step 7: Implement Relationships
GET /v1/games/{id}/relationships – Returns typed, directed relationship edges for a game. The GameRelationship entity captures how games connect: expansions extend base games, reimplementations share lineage, compilations contain other products.
Relationship Types
| Type | Direction | Description |
|---|---|---|
expands | expansion -> base | Source adds content to target; requires target to play |
reimplements | new -> old | Source is a new version of target with mechanical changes |
contains | collection -> item | Source physically includes target (big-box, compilation) |
requires | dependent -> dependency | Source cannot be used without target |
recommends | game -> game | Source suggests target as a companion |
integrates_with | game <-> game | Source and target can be combined for a unified experience |
The endpoint should support filtering by type and direction (inbound/outbound):
GET /v1/games/spirit-island/relationships?type=expands&direction=inbound
For symmetric relationships like integrates_with, both directions are stored so queries work from either side without special-casing.
See Game Relationships for the full specification, including the Spirit Island family tree and Brass reimplementation case studies.
Data Model Checklist: Relationships
- Relationship types: all six types supported (expands, reimplements, contains, requires, recommends, integrates_with)
- Directed edges: source_game_id -> target_game_id with relationship_type
-
Symmetric relationships (
integrates_with) stored in both directions -
Filter by
typeanddirectionparameters -
ordinalfield for display ordering (e.g., expansion release order) -
_linksinclude source and target game references
Step 8: Implement Effective Properties
GET /v1/games/{id}/effective-properties?with=slug1,slug2 – Returns the effective (expansion-modified) properties for a game given a specific set of expansions. This is the endpoint that powers the “what if I own these expansions?” question.
Three-Tier Resolution
The system resolves effective properties using a three-tier strategy:
Tier 1: Look up ExpansionCombination record for the exact expansion set -> use if found
Tier 2: Sum individual PropertyModification deltas -> use as fallback
Tier 3: Return base game properties -> lowest confidence
The response must include combination_source (one of "explicit", "computed", or "base_only") so consumers know how the effective properties were derived:
{
"base_game": "spirit-island",
"expansions": ["spirit-island-branch-and-claw", "spirit-island-jagged-earth"],
"combination_source": "explicit",
"effective_properties": {
"min_players": 1,
"max_players": 6,
"weight": 4.56,
"min_playtime": 90,
"max_playtime": 150,
"top_at": [2, 3, 4],
"recommended_at": [1, 2, 3, 4, 5, 6]
}
}
Implementation Notes
- Tier 1 is a direct lookup: find an
expansion_combinationsrow whoseexpansion_idsmatches the requested set exactly (order irrelevant). - Tier 2 requires applying
property_modificationsfor each requested expansion. Forsetmodifications, the last one wins (by release date). Foraddmodifications, sum the values. Formultiply, apply multiplicatively. - Tier 3 is a simple pass-through of the base game’s properties.
Multi-expansion combinations produce different results than summing individual deltas. This is why explicit combination records exist – non-linear interactions (e.g., a campaign expansion making 3-player games better) are captured by community-curated data.
See Property Deltas & Combinations and Effective Mode for the full specification and worked examples.
Data Model Checklist: Effective Properties
- Three-tier expansion resolution: explicit -> computed -> base_only
-
combination_sourcefield in response:"explicit","computed", or"base_only" -
Tier 1: exact-match lookup on
expansion_combinationsby expansion set -
Tier 2:
PropertyModificationdelta sum with correctset/add/multiplysemantics - Tier 3: base game properties pass-through when no delta data exists
- Effective properties include: min/max_players, min/max_playtime, weight, top_at, recommended_at, min_age
Step 9: Implement Compound Search
The POST /v1/games/search endpoint is the showcase feature. It accepts a SearchRequest JSON body with up to 9 filter dimensions. The composition rules:
- Cross-dimension: AND (all active dimensions must be satisfied)
- Within dimension: OR (any value within one dimension matches)
- Exclusion:
_notparameters remove matches
The 9 Filter Dimensions
- Rating & Confidence –
rating_min,rating_max,min_rating_votes,confidence_min - Weight –
weight_min,weight_max,min_weight_votes - Player Count –
players,players_min,players_max,top_at,recommended_at - Play Time –
playtime_min,playtime_max,playtime_source,playtime_experience - Age –
age_min,age_max,age_source - Game Type & Mechanics –
type,mode,mechanics,mechanics_all,mechanics_not - Theme –
theme,theme_not - Metadata –
designer,publisher,category,year_min,year_max - Corpus & Archetype –
corpus,corpus_rating_min(aspirational)
Example: “cooperative games, weight 2.5-3.5, highly rated at exactly 4 players” requires joining across games, game_mechanics, and player_count_ratings in a single query.
Effective Mode on Search
When effective=true is set on a search request, the filtering system does not just search base game properties – it searches across all known expansion combinations. This is what makes queries like “games that support 6 players with expansions” possible.
The response includes matched_via metadata when a game matches through an expansion combination rather than its base properties:
{
"matched_via": {
"type": "expansion_combination",
"expansions": [
{ "slug": "spirit-island-jagged-earth", "name": "Jagged Earth" }
],
"effective_properties": {
"min_players": 1,
"max_players": 6
},
"resolution_tier": 1
}
}
The resolution_tier (1 = explicit combination, 2 = delta sum, 3 = base fallback) tells consumers the confidence level of the match.
See Search Endpoint and Effective Mode for the full request/response schemas and worked examples. See Filtering & Windowing for the compositional model.
Data Model Checklist: Compound Search
- Compound filtering: 9 dimensions, AND cross-dimension, OR within-dimension
-
Effective mode:
effective=truefilters against expansion-modified properties -
matched_viaresponse metadata when match comes from expansion combination -
resolution_tierin response: 1 (explicit), 2 (computed), 3 (base fallback) - Rating confidence: confidence score (0.0-1.0) exposed in Game entity
-
Mechanics filtering:
mechanics(OR),mechanics_all(AND),mechanics_not(NOT) - Request validation returns RFC 9457 Problem Details on invalid input
Step 10: Add the Materialization Pipeline
The Game entity’s aggregate fields (rating, rating_confidence, rank_overall, etc.) are materialized from raw input data – not computed on every API request. This separation of raw votes from computed aggregates is a deliberate architectural decision documented in Materialization.
Raw Vote Tables
Add raw vote tables via SQL migration. These store the individual, immutable records that feed materialization:
-- migrations/0001_raw_vote_tables.sql
CREATE TABLE rating_votes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
game_id UUID NOT NULL REFERENCES games(id),
rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 10),
declared_scale VARCHAR(10), -- Layer 1 vote context (rating-model.md)
play_count INTEGER, -- how many times voter has played this game
experience_level VARCHAR(50), -- first_play, learning, experienced, expert
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE weight_votes_raw (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
game_id UUID NOT NULL REFERENCES games(id),
weight NUMERIC(2,1) NOT NULL CHECK (weight >= 1.0 AND weight <= 5.0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Raw records are append-only. A new rating vote is inserted; it does not update a running total in place. This preserves the full distribution for statistical analysis and makes the raw data exportable (Pillar 3).
Vote Submission Endpoint
POST /v1/games/{id}/ratings – Submit a raw rating vote. Accepts a numeric rating (1-10) with optional vote context (declared scale preference, play count, experience level per rating-model.md Layer 1 input contract). Inserts into rating_votes and returns the created record. The submitted vote is not immediately reflected in the Game entity’s aggregate fields – it becomes visible after the next materialization run.
POST /v1/games/{id}/weight – Submit a raw weight vote. Accepts a numeric weight (1.0-5.0 per weight-model.md). Inserts into weight_votes_raw.
Materialization Endpoint
POST /v1/admin/materialize – Trigger a batch recomputation of all materialized aggregates. This endpoint runs the same logic as the scheduled cron job and is useful during development and after bulk data imports. In production, protect it with admin-only authentication.
Execution Order
The materialization must run in dependency order:
- Per-game aggregates first –
rating,rating_votes,rating_distribution,rating_stddevfromrating_votes;weight,weight_votesfromweight_votes_raw - Global parameters second – Compute the global mean rating from the freshly-updated per-game averages
- Rating confidence third –
rating_confidenceis the spec-level trust signal (rating-model.md Layer 3), computed from three factors: sample size, distribution shape (stddev), and deviation from global mean. This depends on steps 1 and 2. - Rankings fourth –
rank_overallsorts all games by an implementation-chosen ranking method (the spec recommends Dirichlet-prior Bayesian scoring as Layer 4 guidance, but any method is valid) - Derived fields fifth –
top_player_counts,recommended_player_countsfromplayer_count_ratings; experience multipliers from play logs - Snapshots last – Write a
GameSnapshotrow capturing the freshly-computed aggregates for longitudinal trend analysis (ADR-0036)
Steps 1-3 can be parallelized per-game. Steps 4 and 6 are global operations.
The job must be idempotent – safe to re-run at any time without producing incorrect results. Each run reads the current raw data and overwrites the materialized fields. Running the job twice with no new votes produces identical output.
See Materialization for the full architectural rationale and the Deploying Guide “Materialization Jobs” section for Kubernetes CronJob configuration.
Data Model Checklist: Materialization
- Materialization architecture: raw input data (Tier 1) separated from materialized aggregates (Tier 2)
-
Raw vote tables:
rating_votes,weight_votes_rawcreated via versioned migration -
Vote submission endpoints: rating votes (
POST /v1/games/{id}/ratings) and weight votes (POST /v1/games/{id}/weight) - Rating votes capture Layer 1 context: declared scale, play count, experience level
- Materialization execution order: per-game aggregates -> global mean -> confidence -> rankings -> derived fields -> snapshots
- Job is idempotent: re-running produces identical output
-
updated_atfield on Game entity indicates when aggregates were last refreshed -
Rating confidence:
rating_confidence(Layer 3, spec-level) computed from sample size, distribution shape, and deviation from global mean -
Ranking:
rank_overallcomputed from implementation-chosen ranking method (Layer 4 recommends Dirichlet-prior Bayesian) -
Weight materialized from
weight_votes_raw -
Derived fields:
top_player_counts,recommended_player_countsfrom per-count ratings -
Snapshots:
GameSnapshotwritten as materialization side effect
Step 11: Containerize
Build your server as a multi-stage container image. The pattern is language-agnostic – replace the build stage with your stack:
# Stage 1: Build
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
RUN npm run build
# Stage 2: Runtime (distroless -- see ADR-0021)
FROM gcr.io/distroless/nodejs22-debian12
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
EXPOSE 8080
CMD ["dist/server.js"]
Key requirements:
- Distroless base (ADR-0021) – No shell, no package manager, minimal attack surface. Target < 50MB final image.
- Health endpoints – Expose
/healthz(liveness) and/readyz(readiness). - Tag by git SHA, never
latest(ADR-0024):
docker build -t ghcr.io/your-org/opentabletop:$(git rev-parse --short HEAD) .
docker push ghcr.io/your-org/opentabletop:$(git rev-parse --short HEAD)
Data Model Checklist: Containerization
- Distroless image: no shell, no package manager in runtime stage
- Multi-stage build: build tools not present in final image
-
Git SHA tagging: image tagged by commit hash, never
latest -
Health endpoints:
/healthzand/readyzresponding inside the container - 12-factor config: all configuration from environment variables
Step 12: Generate Client SDKs
Any OpenAPI-compatible code generator can produce client libraries from the spec:
| Generator | Languages | Command |
|---|---|---|
| openapi-generator | 50+ (Rust, Python, TypeScript, Java, Go, …) | openapi-generator-cli generate -i spec/bundled/openapi.yaml -g python -o my-sdk/ |
| oapi-codegen | Go | oapi-codegen -package api spec/bundled/openapi.yaml > api.gen.go |
| openapi-typescript | TypeScript | npx openapi-typescript spec/bundled/openapi.yaml -o schema.d.ts |
Data Model Checklist: SDKs
- Bundled spec used as generator input (not the multi-file source)
- Generated SDK validates response shapes against spec schemas
- SDK handles keyset pagination cursors
-
SDK includes typed models for
SearchRequestandGameentities
Step 13: Validate Conformance
To verify your implementation conforms to the spec:
- Schema validation – Ensure your API responses match the schemas in
spec/schemas/ - Endpoint coverage – Implement the paths defined in
spec/paths/ - Pagination – Use keyset (cursor-based) pagination per ADR-0012
- Error format – Return RFC 9457 Problem Details per ADR-0015
- Filtering semantics – AND across dimensions, OR within dimensions, NOT via
_notparameters - Sample data round-trip – Load the sample data, query it through your API, and verify the response shapes match the spec examples
A formal conformance test suite is a future goal (see ADR-0045).
Data Model Checklist: Conformance
- Data provenance: input contracts documented, voter context captured
-
All responses match schemas in
spec/schemas/ - Keyset pagination on all list endpoints
- HAL-style _links on all entity responses
- Three-tier expansion resolution produces correct results for sample data
-
Effective mode on compound search returns correct
matched_viametadata - Materialization pipeline produces correct aggregate values from raw votes
- All six relationship types queryable with correct directionality
Recommended Architecture
The ADRs in the Infrastructure & Implementation Guidance section document recommended patterns for production deployments:
- Twelve-factor design (ADR-0020) – Config from environment, stateless processes, port binding
- Container images (ADR-0021) – Distroless base images, multi-stage builds
- Observability (ADR-0023) – Structured JSON logging, OpenTelemetry traces and metrics
- Database (ADR-0027, ADR-0029) – PostgreSQL with full-text search, versioned SQL migrations
- Caching (ADR-0028) – Cache-Control headers and ETags
These are recommendations, not requirements. A conforming server built with Django and MySQL is just as valid as one built with Axum and PostgreSQL, provided it implements the API contract correctly.
Next: Deploying
Once your server is built and passing conformance checks, see the Deploying & Operating guide for container images, Kubernetes manifests, database operations, observability setup, and production checklists.
Deploying & Operating
This guide covers how to deploy and operate a conforming OpenTabletop server in production. It assumes you have already built a server following the Implementer’s Guide and are ready to run it.
The guide is practical – every section includes concrete examples you can adapt. For the rationale behind these patterns, see the Infrastructure & Implementation Guidance ADRs.
Local Development Stack
Start with a local stack that mirrors production. This docker-compose.yml runs PostgreSQL with schema auto-loaded on first startup:
# docker-compose.yml
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_DB: opentabletop
POSTGRES_USER: ot
POSTGRES_PASSWORD: localdev
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
- ./data/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql
- ./data/seed.sql:/docker-entrypoint-initdb.d/02-seed.sql
volumes:
pgdata:
For production observability, add an OpenTelemetry collector (ADR-0023):
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
ports:
- "4317:4317" # gRPC OTLP receiver
- "4318:4318" # HTTP OTLP receiver
- "8889:8889" # Prometheus exporter
volumes:
- ./otel-config.yaml:/etc/otelcol-contrib/config.yaml
Start it and load sample data:
docker compose up -d
node scripts/load-samples.js --connection "postgresql://ot:localdev@localhost/opentabletop"
Verify:
curl http://localhost:8080/v1/games/spirit-island
curl http://localhost:8080/healthz
Container Image
Build your server as a multi-stage container image. The pattern is language-agnostic – replace the build stage with your stack:
# Stage 1: Build
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
RUN npm run build
# Stage 2: Runtime (distroless -- see ADR-0021)
FROM gcr.io/distroless/nodejs22-debian12
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
EXPOSE 8080
CMD ["dist/server.js"]
Replace the Node.js images above with the latest stable version for your language (rust:latest, golang:latest, python:3-slim, etc.). Pin to a major version tag rather than a specific patch to keep builds reproducible without going stale.
Key requirements:
- Distroless base (ADR-0021) – No shell, no package manager, minimal attack surface. Target < 50MB final image.
- Health endpoints – Expose
/healthz(liveness: “process is alive”) and/readyz(readiness: “can serve traffic, database connected”). - Tag by git SHA, never
latest(ADR-0024):
docker build -t ghcr.io/your-org/opentabletop:$(git rev-parse --short HEAD) .
docker push ghcr.io/your-org/opentabletop:$(git rev-parse --short HEAD)
Configuration (12-Factor)
All configuration comes from environment variables (ADR-0020). Never bake secrets into images.
| Variable | Required | Default | Description |
|---|---|---|---|
DATABASE_URL | Yes | – | PostgreSQL connection string |
PORT | No | 8080 | HTTP listen port |
REDIS_URL | No | – | Redis connection string (cache layer) |
LOG_LEVEL | No | info | debug, info, warn, error |
LOG_FORMAT | No | json | json for production, pretty for local dev |
OTEL_EXPORTER_OTLP_ENDPOINT | No | – | OpenTelemetry collector endpoint |
OTEL_SERVICE_NAME | No | opentabletop | Service name in traces/metrics |
API_KEY_SALT | Yes | – | HMAC salt for API key hashing |
RATE_LIMIT_ANONYMOUS | No | 60 | Requests/minute for unauthenticated clients |
RATE_LIMIT_AUTHENTICATED | No | 600 | Requests/minute for API key holders |
CORS_ORIGINS | No | * | Comma-separated allowed origins |
For Kubernetes, use a ConfigMap for non-sensitive values and Secrets for credentials:
apiVersion: v1
kind: ConfigMap
metadata:
name: opentabletop-config
data:
PORT: "8080"
LOG_LEVEL: "info"
LOG_FORMAT: "json"
OTEL_SERVICE_NAME: "opentabletop"
RATE_LIMIT_ANONYMOUS: "60"
RATE_LIMIT_AUTHENTICATED: "600"
Kubernetes Deployment
Complete manifests for a production deployment. Adjust resource requests and replica counts for your scale.
Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: opentabletop
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
maxSurge: 1
selector:
matchLabels:
app: opentabletop
template:
metadata:
labels:
app: opentabletop
spec:
terminationGracePeriodSeconds: 30
containers:
- name: server
image: ghcr.io/your-org/opentabletop:abc1234
ports:
- containerPort: 8080
envFrom:
- configMapRef:
name: opentabletop-config
- secretRef:
name: opentabletop-secrets
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: "1"
memory: 512Mi
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /readyz
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
Service, Ingress, HPA
apiVersion: v1
kind: Service
metadata:
name: opentabletop
spec:
selector:
app: opentabletop
ports:
- port: 80
targetPort: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: opentabletop
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
tls:
- hosts: [api.yourdomain.com]
secretName: opentabletop-tls
rules:
- host: api.yourdomain.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: opentabletop
port:
number: 80
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: opentabletop
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: opentabletop
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: opentabletop
spec:
minAvailable: 1
selector:
matchLabels:
app: opentabletop
Graceful Shutdown
Your server must handle SIGTERM by:
- Stop accepting new connections
- Finish in-flight requests (within
terminationGracePeriodSeconds) - Close database connections
- Exit cleanly
This ensures zero-downtime rolling updates. The readinessProbe failure during shutdown prevents new traffic from routing to a terminating pod.
Database Operations
PostgreSQL Setup
Recommended version: PostgreSQL 16+ (improved JSONB performance, parallel query improvements, and MERGE support).
The recommended schema creates all tables, indexes, and the full-text search tsvector column. For production, also consider:
- Connection pooling – Use PgBouncer or your framework’s built-in pool. Target 10-20 connections per pod, sized to your PostgreSQL
max_connections. - Read replicas – Route
/export/gamesand trend endpoints to replicas. These are read-heavy, long-running queries that should not compete with interactive API requests.
Migrations
Follow the ADR-0029 naming convention:
migrations/
0001_initial_schema.sql
0002_add_experience_playtime.sql
0003_add_game_snapshots.sql
Use any SQL-native migration runner (golang-migrate, Flyway, dbmate, or plain psql). Never use ORM-generated migrations – the schema is the spec’s recommended design and should be maintained as explicit SQL.
Index Tuning
The compound filtering workload (Pillar 2) drives these critical indexes:
-- Mechanic filtering (AND/OR/NOT queries join through this table)
CREATE INDEX idx_game_mechanics_mechanic ON game_mechanics(mechanic_id);
-- Compound sort (most common: rating desc with minimum vote count)
CREATE INDEX idx_games_rating ON games(rating DESC, rating_votes DESC);
-- Full-text search (weighted: name > short desc > full desc)
CREATE INDEX idx_games_search ON games USING GIN(search_vector);
-- Year-based trend queries
CREATE INDEX idx_games_year ON games(year_published);
Monitor slow queries with pg_stat_statements and run EXPLAIN ANALYZE on your compound filter queries to verify index usage.
Backup
- Automated daily backups with point-in-time recovery (PITR) using WAL archiving
- Test restores monthly – a backup you have not restored is not a backup
- Managed services (AWS RDS, Cloud SQL, Supabase) handle this automatically
Materialization Jobs
The Game entity’s aggregate fields (rating, rating_confidence, rank_overall, etc.) are materialized from raw input data – not computed on every API request. See Materialization for the full architectural rationale. In production, a scheduled job performs this materialization.
Execution Order
The materialization must run in dependency order:
- Per-game aggregates first –
rating,rating_votes,rating_distribution,rating_stddev,weight,weight_votes,community_playtime_*,community_suggested_age,owner_count,wishlist_count,total_plays, experience multipliers - Global parameters second – Compute the global mean rating from the freshly-updated per-game averages
- Rating confidence third –
rating_confidenceis the spec-level trust signal (rating-model.md Layer 3), computed from sample size, distribution shape, and deviation from global mean - Rankings fourth –
rank_overallcomputed using an implementation-chosen ranking method (Layer 4 recommends Dirichlet-prior Bayesian) - Derived fields fifth –
top_player_counts,recommended_player_countsfrom per-count ratings - Snapshots last – Write
GameSnapshotrows as a side effect of materialization (ADR-0036)
Steps 1-3 can be parallelized per-game. Step 4 is a single global sort.
Idempotency
The materialization job must be idempotent – safe to re-run at any time without producing incorrect results. Each run reads the current Tier 1 data and overwrites Tier 2 fields. Running the job twice with no new votes produces identical output. This means you can safely retry on failure, run manually for debugging, or trigger an extra run after a bulk data import.
Kubernetes CronJob
Schedule the materialization as a Kubernetes CronJob that runs daily at a low-traffic hour:
apiVersion: batch/v1
kind: CronJob
metadata:
name: opentabletop-materialize
spec:
schedule: "0 4 * * *" # Daily at 04:00 UTC
concurrencyPolicy: Forbid # Never run two at once
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
jobTemplate:
spec:
backoffLimit: 2
activeDeadlineSeconds: 3600 # Kill if stuck for 1 hour
template:
spec:
restartPolicy: OnFailure
containers:
- name: materialize
image: ghcr.io/your-org/opentabletop:abc1234
command: ["node", "dist/jobs/materialize.js"]
envFrom:
- configMapRef:
name: opentabletop-config
- secretRef:
name: opentabletop-secrets
resources:
requests:
cpu: 500m
memory: 512Mi
limits:
cpu: "2"
memory: 1Gi
Key settings:
concurrencyPolicy: Forbidprevents overlapping runs if a previous job is still in progress.activeDeadlineSecondskills a stuck job before the next scheduled run.backoffLimit: 2retries on transient failures (safe because the job is idempotent).
Replace the command with your implementation’s materialization entrypoint (e.g., cargo run --bin materialize for Rust, python -m opentabletop.materialize for Python).
Manual Trigger
The demo API exposes POST /v1/admin/materialize for manual triggering – useful after bulk data imports or during development. This endpoint runs the same logic as the cron job. In production, protect it with admin-only authentication.
Observability
Instrument your server with OpenTelemetry (ADR-0023). The three signals:
Metrics (Prometheus)
Expose a /metrics endpoint with at minimum:
| Metric | Type | Description |
|---|---|---|
http_requests_total | Counter | Total requests by method, path, status |
http_request_duration_seconds | Histogram | Request latency by endpoint |
db_query_duration_seconds | Histogram | Database query latency |
db_connections_active | Gauge | Current active DB connections |
cache_hits_total / cache_misses_total | Counter | Redis cache effectiveness |
export_requests_active | Gauge | In-flight bulk export streams |
Logs (Structured JSON)
Every log line should include:
{"level":"info","msg":"request completed","method":"GET","path":"/v1/games","status":200,"duration_ms":42,"trace_id":"abc123"}
The trace_id correlates logs to distributed traces. Use a log aggregator (Loki, CloudWatch, Datadog) to search and alert.
Traces (OTLP)
Configure the OTEL SDK to export traces to your collector. Instrument at minimum:
- HTTP handler spans (automatic with most frameworks)
- Database query spans (manual or via instrumented client)
- Cache lookup spans
For production, sample at 10-25% to control costs while maintaining visibility into tail latency.
Suggested Alerts
| Alert | Condition | Severity |
|---|---|---|
| High error rate | 5xx rate > 1% for 5 minutes | Critical |
| Slow responses | p95 latency > 2s for 5 minutes | Warning |
| DB connections saturated | Active > 80% of max for 5 minutes | Warning |
| Export queue backing up | Active exports > 5 for 10 minutes | Warning |
| Pod restarts | > 3 restarts in 15 minutes | Critical |
Sizing & Capacity Planning
Reference sizing for a dataset of ~100,000 games with full taxonomy, polls, and snapshots:
| Tier | Pods | CPU/Pod | Memory/Pod | PostgreSQL | Redis | Expected QPS |
|---|---|---|---|---|---|---|
| Small (hobby) | 1 | 0.5 vCPU | 512 MB | Micro (2 vCPU, 1 GB) | None | < 10 |
| Medium (community) | 2-3 | 1 vCPU | 1 GB | Small (2 vCPU, 2 GB) | Micro | 10-100 |
| Large (platform) | 3-10 | 2 vCPU | 2 GB | Large (2 vCPU, 16 GB) + replica | Large | 100-1000 |
Storage Growth
| Data Type | Per Game | 100k Games | Growth Rate |
|---|---|---|---|
| Game records | ~2 KB | ~200 MB | Slow (new publications) |
| Player count ratings | ~200 B | ~20 MB | Moderate (new votes) |
| Game snapshots (monthly) | ~500 B x 12/yr | ~600 MB/year | Linear |
| Full-text index | ~1 KB | ~100 MB | Tracks game records |
| Total (year 1) | ~1 GB |
PostgreSQL handles this comfortably. You will hit CPU limits on compound filtering queries long before you hit storage limits.
Security Checklist
- TLS everywhere – Terminate at ingress or load balancer. No plaintext HTTP in production.
- API keys hashed – Store HMAC hashes, not plaintext keys (ADR-0016).
- Rate limiting enforced – 60/min anonymous, 600/min authenticated.
-
CORS restricted – Set
CORS_ORIGINSto your known frontends. Do not leave*in production. - Database not publicly accessible – Private subnets, security groups, or network policies.
-
Secrets in Secret store –
DATABASE_URLandAPI_KEY_SALTin Kubernetes Secrets (or Vault/SOPS), never in ConfigMap or image. - Container image scanned – Run Trivy or Grype in CI. Block deployment on critical CVEs.
- No shell in runtime image – Distroless images have no shell to exploit.
Production Checklist
Before going live:
- Schema migrations applied and verified
- Sample data or real data loaded and queryable
-
Health endpoints (
/healthz,/readyz) responding -
Metrics endpoint (
/metrics) scraped by Prometheus - Structured JSON logs flowing to aggregator
- Traces sampled and visible in backend
- HPA configured and tested under load
-
PodDisruptionBudget set (
minAvailable: 1) - Database backups configured and restore tested
- TLS certificate provisioned and auto-renewing
- Rate limiting verified (test both anonymous and authenticated tiers)
- Rolling update tested (deploy a new image, verify zero downtime)
- Rollback tested (revert to previous SHA, verify recovery)
- Alert rules configured and notification channel verified
Migrating from BGG
This guide maps BoardGameGeek XML API concepts to their OpenTabletop equivalents.
While this guide focuses on BGG as the most common migration source for English-speaking communities, OpenTabletop servers can be populated from any data source – regional databases, publisher records, community curations, or other platforms. The specification is agnostic about data origin. If you are building a server for a non-English community using local data sources, the entity mapping principles here still apply even if the source system is not BGG.
Strangler Fig Approach (ADR-0032)
You don’t have to migrate all at once. The recommended pattern:
flowchart LR
A[Your App] --> B{API Gateway}
B -->|Migrated endpoints| C[Conforming Server]
B -->|Not yet migrated| D[BGG XML API]
C --> E[PostgreSQL]
D --> F[BGG Servers]
Route traffic through a gateway that sends requests to OpenTabletop for migrated endpoints and falls back to BGG for the rest. Migrate one endpoint at a time.
Endpoint Mapping
| BGG XML API | OpenTabletop | Notes |
|---|---|---|
GET /xmlapi2/thing?id=123 | GET /games/{id} or GET /games/{slug} | Use slug for human-readable URLs |
GET /xmlapi2/thing?id=123&type=boardgameexpansion | GET /games/{id}/expansions | Type filtering built in |
GET /xmlapi2/search?query=spirit | GET /search?q=spirit | Full-text search with typo tolerance |
GET /xmlapi2/hot?type=boardgame | GET /games?sort=trending&order=desc | Trending sort option |
GET /xmlapi2/collection?username=foo | GET /users/{id}/collection (future) | User data deferred to v2 |
Field Mapping
| BGG Field | OpenTabletop Field | Notes |
|---|---|---|
@id | bgg_id | Stored as cross-reference |
name[@type='primary'] | name | Primary name |
minplayers | min_players | Same semantics |
maxplayers | max_players | Same semantics |
poll[@name='suggested_numplayers'] | GET /games/{id}/player-count-ratings | Numeric 1-5 per-count ratings |
minplaytime | min_playtime | Publisher-stated |
maxplaytime | max_playtime | Publisher-stated |
| (not available) | community_min_playtime, community_max_playtime | Community-reported – new |
statistics/ratings/averageweight | weight | Same 1-5 scale |
statistics/ratings/average | rating | Same scale |
link[@type='boardgamemechanic'] | ?include=mechanics | Embedded via include param |
link[@type='boardgamecategory'] | ?include=categories | Embedded via include param |
link[@type='boardgameexpansion'] | GET /games/{id}/relationships?type=expands | Typed relationship query |
Key Differences
XML vs JSON
BGG returns XML. Conforming OpenTabletop implementations return JSON with HAL-style _links.
Polling vs Structured Data
BGG embeds poll data inline in the thing response as XML. The OpenTabletop specification defines player count ratings as a dedicated endpoint with a numeric 1-5 scale per count – each player count has an independent average rating, vote count, and standard deviation. This replaces BGG’s three-tier Best/Recommended/Not Recommended model (ADR-0043).
No Expansion-Aware Filtering
BGG has no concept of filtering by expansion-modified properties. The specification’s effective=true parameter is entirely new functionality.
No Community Play Times
BGG tracks play logs but doesn’t expose aggregated community play times through the API. The specification defines community_min_playtime and community_max_playtime as first-class fields, plus experience-adjusted playtime via GET /games/{id}/experience-playtime.
Rate Limiting
BGG has undocumented rate limits that change without notice. The specification defines explicit tiered limits (60/min public, 600/min authenticated) with standard X-RateLimit-* headers.
Cross-Referencing
The specification includes a bgg_id field on every game entity for cross-referencing. You can look up games by BGG ID on any conforming server:
# Find a game by its BGG ID (using the reference implementation)
curl "https://api.opentabletop.org/v1/games?bgg_id=162886"
This makes it straightforward to maintain a mapping between BGG and any OpenTabletop-conforming system during migration.
Governance Model
OpenTabletop uses an RFC-based governance model designed to evolve with the project’s maturity.
Current Phase: Founder-Led (BDFL)
While the project has fewer than 10 active contributors, the founder makes final decisions on spec changes, ADRs, and project direction. This keeps velocity high during the bootstrap phase.
Future Phase: Steering Committee
At 10+ active contributors, the project transitions to an elected steering committee:
- 5 members, elected annually by active contributors
- Staggered terms – 2-3 seats rotate each year for continuity
- Decisions by majority vote with the founder holding a tiebreaker vote during the first transition year
- Geographic and linguistic diversity – the committee should aim to represent the international communities the specification serves, not just English-speaking contributors
Language and Accessibility
OpenTabletop is a global standard. Contributions, discussions, and RFC proposals are welcome in any language. The core team will work with community translators to ensure key discussions are accessible across language boundaries. Non-English contributions are valued equally – a Japanese developer’s RFC is as valid as an English one.
Decision Process
Spec Changes (RFC Process)
Changes to the OpenAPI specification follow a formal RFC process:
flowchart LR
A[RFC Proposal] --> B[GitHub Discussion]
B --> C{Community Review\n7 days minimum}
C --> D[Steering Committee Vote]
D -->|Accepted| E[Spec + Docs PR]
D -->|Rejected| F[Closed with rationale]
E --> G[Merged]
- Propose: Open a GitHub Discussion using the RFC template
- Discuss: Minimum 7-day community review period
- Vote: Steering committee (or BDFL) votes
- Implement: Accepted RFCs require a PR with the spec change and updated documentation
Architecture Decisions (ADRs)
Significant technical decisions are recorded as ADRs:
- Use
/create-adrto propose a new decision - ADRs start as
proposed, move toacceptedafter review - ADRs are immutable once accepted – only the status field changes
- To change a decision, create a new ADR that supersedes the old one
Taxonomy and Example Data Corrections
Corrections to the controlled vocabulary (mechanics, categories, themes) and spec example data use a lighter-weight process:
- Open an issue using the Data Correction template
- Provide source references
- One maintainer verifies and merges
Roles
| Role | Responsibility | How to earn |
|---|---|---|
| Contributor | Submit PRs, issues, RFCs | First merged PR |
| Maintainer | Review and merge PRs for a specific area | Consistent quality contributions + nomination |
| Spec Maintainer | Approve spec changes | Deep understanding of the data model + steering committee approval |
| Steering Committee | Vote on RFCs, resolve disputes, set project direction | Election by active contributors |