Chapters The addon protocol

Chapter 3 of 13

The addon protocol

The routes an addon answers: the manifest, Catalogs and their filters, metas and Play Options, caching and versions.

On this page

The protocol is version 1. Routing follows Stremio’s scheme; stream is not reused because a Play Option is not a media URL. All shapes are zod schemas in @camtable/sdk/addon, shared by the extension, the server and you.

GET  /manifest.json                          -> the manifest
GET  /catalog/game/{catalogId}.json          -> { metas: [...] }
GET  /catalog/game/{catalogId}/{extras}.json -> { metas: [...] }   (filters and pages, below)
GET  /meta/game/{id}.json                    -> { meta }
GET  /play/game/{id}.json                    -> { plays: [...] }
POST {engine.url}/apply                      -> engine reply    (engine mode only)
POST {engine.url}/timeout                    -> engine reply    (engine mode only)

Every route must answer CORS for all origins: CamTable reads them from an extension page. An addon may be nothing but static files. id in a route may contain : (for example meta/game/my-game:tap-race.json); it is sent unescaped.

A 404 means “nothing for this id”. When a catalog, meta or play route answers HTTP 404, CamTable reads it as “this addon has nothing here”: no plays, no meta, an empty catalog, and no “addon did not respond” note. A timeout, any other non-2xx status, a network error or an invalid body is a failure and is reported to the player. So a static host needs no extra rules for ids it does not implement: it simply has no file for them. The 404 must carry the same CORS headers as every other answer (CamTable cannot read a response the browser blocks, and that would count as a failure), and it must be a real 404, not a single-page-app fallback that answers the missing JSON with index.html and a 200.

The manifest

{
  "id": "my-game",
  "version": "1.0.0",
  "name": "My Game",
  "description": "A first CamTable game.",
  "camtable": "1",
  "resources": ["catalog", "meta", "play", "engine"],
  "types": ["game"],
  "idPrefixes": ["my-game:"],
  "catalogs": [{ "type": "game", "id": "main", "name": "Games", "names": { "tr": "Oyunlar" } }],
  "permissions": ["players.profile"],
  "donationUrl": "https://example.com/support",
  "logo": "https://my-game.example/logo.svg"
}
  • camtable is the protocol version and is required. A manifest without it, or with a major version CamTable does not speak, is rejected at install with a readable reason.
  • Unknown keys are ignored, so a manifest written for a later minor revision still installs.
  • resources are plain names ("catalog") or, as in Stremio, { "name", "types", "idPrefixes" } to narrow one resource. CamTable asks an addon about a game only when a resource’s name, type and id prefix all match.
  • idPrefixes say which game ids you understand: bgg: for games with a BoardGameGeek entry (CamTable’s official meta addon lists those, keyed by BoardGameGeek id), and your own prefix for original games.
  • Game ids are bgg:<number> for games with a BoardGameGeek entry and <addonId>:<slug> for originals. There is no translation layer.
  • catalogs are your lists of games, each a row on Keşfet (see “Catalogs and Catalog Filters” below). A Catalog may carry a searchIndex (a static file CamTable searches itself) and a skip extra listing shard files, so a large catalog can be static too.
  • donationUrl is the only monetisation CamTable offers: players see it on your games. CamTable takes no revenue share and paid plans are never tied to a game.

Validate a manifest the way CamTable does:

import { validateManifest } from "@camtable/sdk/addon";

const result = validateManifest(JSON.parse(await readFile("dist/manifest.json", "utf8")));
if (!result.ok) console.error(result.issues.join("\n")); // "permissions.0: Invalid enum value ..."

Catalogs and Catalog Filters

Each Catalog you declare is a row on Keşfet, titled with its name and your addon’s name, with a Tümünü gör link to the whole list. Rows keep the order of your catalogs, so put your main list first; a player sees an addon’s first three Catalogs by default and can show or hide any of them. The games in a Catalog and their order are yours: CamTable ranks nothing itself. Sorting is never a filter: a popular list, a newest list and an A–Z list are three Catalogs.

  • name is required and is the fallback. names (optional) gives the name by BCP 47 language tag, such as { "tr": "Popüler" }; CamTable shows the player’s language when you give it.
  • extra lists what a request to the Catalog may carry. CamTable understands four names and ignores any other:
    • search: the player’s query (or use a static searchIndex instead). Search is one list merged across every addon; filters never apply to it.
    • skip: the page offset; options lists the offsets you serve, such as ["100", "200"].
    • genre: a Catalog Filter with options you name, such as ["party", "strategy"].
    • players: a Catalog Filter with options from 2, 3, 4, 5, 6+ (offer any of them). players=N means “playable by N”: the game’s minPlayers ≤ N ≤ maxPlayers. 6+ means maxPlayers ≥ 6. A game with no player counts matches no players value.
  • optionLabels (optional, on an extra) gives each option’s text by language: { "party": { "tr": "Parti oyunları", "en": "Party games" } }. Without a label the player sees the raw value. options stay plain strings, and the raw value is what a request carries.
  • isRequired: a Catalog with a required extra other than search cannot show anything without a choice, so it never gets a row.

When a player chooses filters or a page, CamTable puts the chosen extras into the path in a fixed order, genre, players, skip, each only when chosen, joined with &, with raw values:

GET /catalog/game/popular.json                              no filter, first page
GET /catalog/game/popular/genre=party.json
GET /catalog/game/popular/players=6+.json
GET /catalog/game/popular/genre=party&players=4&skip=100.json

So a static addon can serve every combination it supports from predictable file names, and need not build them all: a combination you do not serve answers 404, which the player sees as “no games”, not as an error. A manifest declaring both filters on two Catalogs:

"catalogs": [
  {
    "type": "game", "id": "popular", "name": "Popular", "names": { "tr": "Popüler" },
    "extra": [
      { "name": "genre", "options": ["party", "strategy"],
        "optionLabels": { "party": { "tr": "Parti oyunları" }, "strategy": { "tr": "Strateji" } } },
      { "name": "players", "options": ["2", "3", "4", "5", "6+"] },
      { "name": "skip", "options": ["100"] }
    ]
  },
  { "type": "game", "id": "new", "name": "New releases", "names": { "tr": "Yeni çıkanlar" } }
]

A malformed names or optionLabels (not text by language) is refused at install with a message saying the expected shape. validateManifest from @camtable/sdk/addon checks a manifest the same way before you deploy it.

@camtable/sdk/addon also exports these lists and rules, so a build script need not copy them: PLAYERS_FILTER_OPTIONS (the players options above), CATALOG_FILTER_NAMES, CATALOG_EXTRAS_PATH_ORDER (the order of extras in a path), catalogPath(catalogId, extras) (the file name a request asks for, such as catalog/game/popular/genre=party&players=4.json), playersFilterMatches(option, game), and for a searchIndex file catalogSearchIndexSchema (each game with its terms) and normalizeSearchText, the form CamTable puts a query in before matching it against those terms, so write the terms with it too.

Catalog, meta and play

GET /catalog/game/{catalogId}.json and GET /meta/game/{id}.json return games in one shape (a catalog entry may simply carry less of it):

{ "metas": [{ "id": "my-game:tap-race", "type": "game", "name": "Tap Race",
              "description": "...", "minPlayers": 2, "maxPlayers": 8,
              "releaseYear": 2026, "designers": ["You"], "bggId": 12345,
              "labels": { "tr": "Tıklama Yarışı" }, "descriptions": { "tr": "..." } }] }

@camtable/sdk/addon exports the schemas CamTable reads these with: gameMetaSchema for one game, catalogResponseSchema and metaResponseSchema for the two answers, playResponseSchema for the Play Options below.

GET /play/game/{id}.json lists your Play Options, one per way you can play that game. A game’s detail page shows one Play button per option of every installed addon that offers it.

{
  "plays": [{
    "name": "Play",
    "uiUrl": "https://my-game.example/index.html",
    "minPlayers": 2,
    "maxPlayers": 8,
    "permissions": ["players.profile"],
    "engine": { "url": "https://my-game-engine.example/tap-race" },
    "tabs": [{ "id": "stats", "title": "Stats", "uiUrl": "https://my-game.example/stats.html", "icon": "📊" }],
    "settings": { "fields": [{ "kind": "number", "id": "target", "label": { "en": "Taps to win" },
                               "min": 5, "max": 50, "step": 5, "default": 10 }] }
  }]
}

engine, tabs and settings are optional. settings declares the Game Settings the room host chooses before each match (see Game Settings and the Lobby). engine decides the authority mode (section 8: Host mode and engine mode): declare one and the rules run on your server, leave it out and they run in the host’s browser. In either mode the room stays its host’s for the whole match, and pauses while the host is away (section 8: Host mode and engine mode). An addon can offer a Play Option for a game it did not catalog (for example a bgg: id it has an implementation for): list bgg: in idPrefixes and answer /play/game/bgg:<id>.json.

Once you list bgg:, CamTable asks you about every catalog game, thousands of them. Answer the ones you do not implement with a plain HTTP 404 (a static host does so for free, because it has no file for them). Do not add a catch-all rewrite that answers those ids with a 200 and an empty { "plays": [] }: hosts such as Cloudflare Pages apply redirect and rewrite rules even when a file matches, so the games you do implement would be rewritten too. (An empty { "plays": [] } is still a valid answer if you compute responses in code.) The official addon ships real files for every id it implements and nothing else; unknown ids are a 404 in dev and in production. Your addon may also list a game the meta addon lists under the same bgg: id: the catalog shows one card per id, taken from the first addon in precedence order (the official meta addon before yours), and the Play Options of every addon that plays it are listed under it.

Caching and slow addons

Responses may set cache headers, and CamTable honours them. CamTable calls every matching addon in parallel with a per-addon timeout, so a slow addon never blocks the others, but a slow addon shows nothing: answer quickly.

Versioning

The version you publish in the manifest is pinned when a host opens a room. A running match keeps the version it started with, including its UI URL, tabs, permissions and engine URL, even if you deploy mid-match. Keep old game page URLs alive for as long as matches may be running (a few hours), or serve your deploy under versioned paths.

The protocol version moves with the SDK: @camtable/sdk follows semver and its major version is the protocol version (1.x speaks camtable: "1").