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"
}
camtableis 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.
resourcesare 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.idPrefixessay 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. catalogsare your lists of games, each a row on Keşfet (see “Catalogs and Catalog Filters” below). A Catalog may carry asearchIndex(a static file CamTable searches itself) and askipextra listing shard files, so a large catalog can be static too.donationUrlis 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.
nameis 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.extralists what a request to the Catalog may carry. CamTable understands four names and ignores any other:search: the player’s query (or use a staticsearchIndexinstead). Search is one list merged across every addon; filters never apply to it.skip: the page offset;optionslists the offsets you serve, such as["100", "200"].genre: a Catalog Filter withoptionsyou name, such as["party", "strategy"].players: a Catalog Filter withoptionsfrom2,3,4,5,6+(offer any of them).players=Nmeans “playable by N”: the game’sminPlayers≤ N ≤maxPlayers.6+meansmaxPlayers≥ 6. A game with no player counts matches noplayersvalue.
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.optionsstay plain strings, and the raw value is what a request carries.isRequired: a Catalog with a required extra other thansearchcannot 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").