# api.table.place — provisioning a seeded lobby GENERATED FILE — do not edit by hand. Built from this service's own layout registry, caps and error catalogue by `bun run llms`, and CI fails when the committed copy is stale. Every route, number and error code below is read out of the code that serves this document, so it describes this deployment and not a plan for one. This document is self-contained. If you are an LLM with no access to the tableplace-api repository, everything needed to turn packs into a playable table is here. Canonical URLs: - this document — `https://api.table.place/llms.txt` - **authoring a pack** — `https://table.place/llms.txt` - pack JSON Schema — `https://table.place/pack.schema.json` ## 0. Which document you want Two documents, one handoff, no overlap: | You want to… | Read | | --- | --- | | write a `.tbpp.json` pack — decks, cards, face refs, overlays | `https://table.place/llms.txt` | | turn packs into a lobby your players can open | this one | That document is long, and about a third of it does not apply to you. Read its **§ 5** (face refs — the grammar no schema can express) and **§ 6** (the pack format). You can skip **§ 7** and its schema entirely: scenarios are what this API composes *for* you, and it does not accept one — this API's own `placements` (§ 4.5) is how you say where things go, and it is not that format. Its piece and bag sections are worth reading: a pack's `pieces` do reach the table, once a placement of yours says where each one goes. There are no accounts, no API keys and no registration. You host your card images; table.place hosts nothing. A lobby is temporary — see § 7. ## 1. The whole integration One POST. The packs travel in the request body; nothing is fetched from you server-side. Send a `User-Agent` identifying your integration — any value of your own works. ```js const res = await fetch('https://api.table.place/v1/lobbies', { method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': 'my-integration/1.0' }, body: JSON.stringify({ version: 1, layout: 'sorcery-2p', packs: [packForSeat0, packForSeat1] // full .tbpp.json objects, inline }) }); const { seats } = await res.json(); // seats → [{ seat: 0, url }, { seat: 1, url }] — hand one link to each player ``` Each player opens their link and claims that seat. The table is already laid out when they arrive. > **A request whose `User-Agent` is a bare stdlib default — `Python-urllib/3.14` is the confirmed case — is refused at the edge with a plain-text `error code: 1010` and an HTTP 403.** It is the recognisable default signature that is blocked, not the absence of the header: a request with no `User-Agent` at all reaches this API normally, and `python-requests`, `python-httpx`, `node-fetch`, `Go-http-client`, `axios`, `Java`, `curl` and any value of your own all pass. The 403 does not come from this API, carries no JSON body, and is not in the error catalogue below (§ 8) — if you see it, set a `User-Agent` of your own and retry. > **The one mistake that costs an afternoon:** card faces are **fetched by each player's browser**, not by this service. A pack whose `face` URLs are unreachable — behind auth, on localhost, blocked by CORS or hotlink protection, or simply 404 — provisions perfectly: `201`, seat links, no warning, and a table of **blank white cards**. Nothing in this API can detect it, because nothing here ever loads an image. See § 5 before you POST. ## 2. Routes Base URL `https://api.table.place`. No authentication. `content-type: application/json` on the POST. | Method | Path | What it is | | --- | --- | --- | | GET | `/llms.txt` | this document | | GET | `/v1/layouts` | the layouts that exist, the deck slots each one requires, and where each slot’s deck lands | | POST | `/v1/lobbies` | provision a lobby from packs + a layout id, and seed it | | POST | `/v1/lobbies/validate` | the dry run — the same body, validated and composed, and no lobby is created | | GET | `/v1/lobbies/:id/scenario` | the composed scenario, the packs it references and your placements — the re-seed fallback | | GET | `/healthz` | liveness, for the host | Any other path returns `404 not_found`. ## 3. Layouts — pick one first **A layout is the arrangement you get for free.** You send packs and a layout id, and the layout decides where each deck lands and which way it faces. That is what keeps this a five-line integration, and it is the whole request for most callers. It is not a ceiling. `placements` (§ 4.5) overrides the parts you care about and places what a layout knows nothing about — a token, a counter, an exact card order — while everything you do not mention stays exactly where the layout put it. Read § 3 first anyway: a placement addresses a layout's slots by name, so the layout is still the frame. The layout also dictates **which deck slots every seat's pack must contain**. A pack missing a required slot is a `400 missing_deck_slot` naming the slot it wanted and listing the slots your pack actually has. It dictates face-up too, and that one is not a default you can override: the placement carries the layout's value for the slot, so a deck's own `isFaceUp` in your pack has no effect on how this API deals it. Set it or omit it; the `avatar`, `discard` slots are dealt face-up because the layout says so. `GET https://api.table.place/v1/layouts` returns the same information as JSON, plus each layout's `geometry` — the exact `position` and `rotation` every slot's deck occupies, per seat, in § 4.5's own units. Read it at runtime rather than hardcoding this section: ```sh curl -s https://api.table.place/v1/layouts ``` ### `sorcery-2p` — Sorcery — 2 player Two seats facing each other. Each seat gets its avatar face-up, then its spellbook and atlas face-down beside it. **Seats: 2.** The request must carry exactly 2 packs with `"scope": "player"`, in seat order — `packs[0]` is seat 0. Each of them must supply every required deck slot below: | Deck `slot` | Required | Dealt | What it is | | --- | --- | --- | --- | | `avatar` | yes | face-up | the avatar card, dealt face-up | | `spellbook` | yes | face-down | the spellbook deck | | `atlas` | yes | face-down | the atlas deck | An optional pack with `"scope": "table"` may ride along; its overlay is centred on the felt (a board, a map, a realm image). It is not counted against the seat count. One overlay, because there is one centred position for it — a table pack carrying more is a `400 unsupported_content` (§ 10), and its `decks` belong in the seat packs. ### `duel-2p` — Duel — 2 player Two seats facing each other. Each seat gets its deck face-down and, when the pack carries one, a discard pile face-up beside it — sending the `discard` slot empty starts an empty pile there. Everything else a game needs (a battle line, a score pile, tokens) is placed by you via `placements`; this layout deliberately names no game-specific slots. **Seats: 2.** The request must carry exactly 2 packs with `"scope": "player"`, in seat order — `packs[0]` is seat 0. Each of them must supply every required deck slot below: | Deck `slot` | Required | Dealt | What it is | | --- | --- | --- | --- | | `deck` | yes | face-down | the draw deck | | `discard` | optional | face-up | the discard pile — optional, and an empty deck here starts an empty pile | An optional pack with `"scope": "table"` may ride along; its overlay is centred on the felt (a board, a map, a realm image). It is not counted against the seat count. One overlay, because there is one centred position for it — a table pack carrying more is a `400 unsupported_content` (§ 10), and its `decks` belong in the seat packs. ## 4. The request `POST https://api.table.place/v1/lobbies` | Field | Required | Meaning | | --- | --- | --- | | `version` | yes | must be `1`. Anything else is `400 unsupported_version`. | | `layout` | yes | a layout id from § 3. | | `packs` | yes | an array of complete tbpp pack objects — the file contents, not URLs. One pack whose own `scope` is `"player"` per seat, **in seat order**, plus any whose `scope` is `"table"`. There is no scope field in this request: it lives inside each pack, and it is what assigns seats. | | `placements` | no | an array of placements — exact positions, card orders, tokens, counters — in **this API's** vocabulary, described in § 4.5. Additive to `layout`: what you do not place stays where the layout put it. Up to 100 entries. | | `snapPoints` | no | an array of placement guides — spots and grids a dropped card or piece snaps to, described in § 4.6. Up to 200 entries; a grid is one entry however many cells it covers. | | `players` | no | `{"max": n}`. If present, `n` must equal the layout’s seat count; it is a cross-check, not a way to resize a layout. | | `lobby` | no | the lobby's name, lowercase words/digits/hyphens (e.g. `ember-duel-7`), up to 64 characters. Omit it and one is rolled for you. Taken names are `400 lobby_taken`, never overwritten — and the namespace is **global, unauthenticated and first-come**, shared with every other caller, so any memorable name is probably already gone: omit the field, or expect `lobby_taken` on anything you would think of. This one string is the lobby id, the `:id` in the scenario route, the `lobby` field of the 201 and the `?lobby=` in every seat link — there is no second identifier. | | `ttlSeconds` | no | how long the lobby stays addressable. Clamped to 60 (1m)–86400 (24h); the default is 3600 (1h). | ### 4.1 The rules worth reading twice These are the ones that are otherwise discoverable only by failing: 1. **One player-scoped pack per seat, in seat order.** `scope` lives inside each pack (a tbpp field, not a request field). The player-scoped packs are filtered out of `packs` and taken in the order they appear there: the first is seat 0, the second seat 1. Only their order **relative to each other** matters — a table-scoped pack may sit anywhere in the array, including first, without shifting a seat. Send the wrong number of player-scoped packs and you get `400 seat_count_mismatch`. 2. **Every pack `id` must be distinct.** Placements address content by pack id, so two packs sharing one would put a single seat's cards on both seats. That is `400 duplicate_pack_id` rather than a silent collapse. Two seats playing the same deck still need two packs with different ids. 3. **Table-scoped packs ride along** in the same array and are not counted as seats. Order among them does not matter. 4. **Packs are validated by the client's own parser.** A pack this service accepts is a pack the table can open; a rejected one reports the parser's own complaint and the array index (`400 invalid_pack`). 5. **Nothing is fetched from you.** Packs are sent inline. There is no "pack URL" field, and no callback. 6. **A deck's own `isFaceUp` is ignored.** The layout decides face-up per slot (§ 3) and the placement carries the layout's value. Setting it in your pack changes nothing here; omit it. If you want a slot dealt the other way, that is `faceUp` on a placement (§ 4.5), not a pack field. 7. **The layout constrains which slots exist, not how big they are.** Any number of cards satisfies a slot — one, forty, or none. A required slot present but empty passes validation and puts an empty deck on the table, which no error will mention. 8. **Everything in your packs must be placed — by the layout or by you.** A layout places its own deck slots and one centred table overlay. A `pieces` entry, a deck in a slot the layout does not name, an overlay on a player-scoped pack: each of those needs a `placements` entry (§ 4.5), and without one the request is `400 unsupported_content` naming the content and a placement that would carry it. Nothing is ever dropped in silence. The rule is about content, not keys: it is a **non-empty** `decks` with no placements that is refused on a table-scoped pack, while `"decks": []` carries nothing, needs nothing, and is exactly what § 4.3's table pack sends. 9. **Unknown top-level fields are refused, not ignored.** A field the table in § 4 does not list is `400 unknown_field` naming it and listing the ones a request takes. This used to be a silent swallow — a typo'd `placments` was a lobby quietly missing its placements — and the same rule already applied inside `placements` entries (§ 4.5); now the top level says so too. ### 4.2 Run this first — a request that needs no art Every face here is a `gen:` ref, drawn in the browser from nothing (§ 5.2 of `https://table.place/llms.txt`), so this body works with no hosting of your own. POST it as-is: it proves your end of the integration before art, CORS or a CDN can be to blame for anything. Save the block below as `smoke.json`, then: ```sh curl -sS -X POST https://api.table.place/v1/lobbies -H 'content-type: application/json' -H 'user-agent: my-integration/1.0' -d @smoke.json ``` ```json { "version": 1, "layout": "sorcery-2p", "packs": [ { "$schema": "https://table.place/pack.schema.json", "tbpp": 1, "specVersion": "1.5.0", "id": "smoke-seat-0", "name": "Seat 0", "scope": "player", "decks": [ { "slot": "avatar", "name": "Avatar", "back": "gen:std52/back", "cards": [ { "code": "AS", "name": "Ace of Spades", "face": "gen:std52/AS" } ] }, { "slot": "spellbook", "name": "Spellbook", "back": "gen:std52/back", "cards": [ { "code": "KS", "name": "King of Spades", "face": "gen:std52/KS" }, { "code": "QS", "name": "Queen of Spades", "face": "gen:std52/QS" } ] }, { "slot": "atlas", "name": "Atlas", "back": "gen:std52/back", "cards": [ { "code": "JS", "name": "Jack of Spades", "face": "gen:std52/JS" } ] } ] }, { "$schema": "https://table.place/pack.schema.json", "tbpp": 1, "specVersion": "1.5.0", "id": "smoke-seat-1", "name": "Seat 1", "scope": "player", "decks": [ { "slot": "avatar", "name": "Avatar", "back": "gen:std52/back", "cards": [ { "code": "AH", "name": "Ace of Hearts", "face": "gen:std52/AH" } ] }, { "slot": "spellbook", "name": "Spellbook", "back": "gen:std52/back", "cards": [ { "code": "KH", "name": "King of Hearts", "face": "gen:std52/KH" }, { "code": "QH", "name": "Queen of Hearts", "face": "gen:std52/QH" } ] }, { "slot": "atlas", "name": "Atlas", "back": "gen:std52/back", "cards": [ { "code": "JH", "name": "Jack of Hearts", "face": "gen:std52/JH" } ] } ] } ] } ``` It should return `201`. Open a seat link and you will see four playing cards. That is the whole contract working; everything after this is your content — and iterating on your content belongs on the dry run (§ 4.4), which runs the same validation without spending the shared lobby cap. Provision for real once, here, because only a real lobby proves the relay half; validate everything after that for free. ### 4.3 The same request with hosted art The shape you ship. **Every URL in it is a placeholder** — `cdn.example.com` resolves to nothing, and swapping in your own hosting is the only difference between this and a real integration. This service never fetches them, which is why its own test suite can POST this body unchanged and get a `201`: a valid request and a table with art on it are two different things (§ 5). ```json { "version": 1, "layout": "sorcery-2p", "packs": [ { "$schema": "https://table.place/pack.schema.json", "tbpp": 1, "specVersion": "1.5.0", "id": "ember-duel-ashling", "name": "Ashling", "scope": "player", "decks": [ { "slot": "avatar", "name": "Avatar", "back": "https://cdn.example.com/emberduel/back.png", "cards": [ { "code": "avatar-ashling", "name": "Ashling", "face": "https://cdn.example.com/emberduel/avatar-ashling.png" } ] }, { "slot": "spellbook", "name": "Spellbook", "back": "https://cdn.example.com/emberduel/back.png", "cards": [ { "code": "ashling-sp1", "name": "Ember Bolt", "face": "https://cdn.example.com/emberduel/ashling-sp1.png" }, { "code": "ashling-sp2", "name": "Ashen Ward", "face": "https://cdn.example.com/emberduel/ashling-sp2.png" } ] }, { "slot": "atlas", "name": "Atlas", "back": "https://cdn.example.com/emberduel/back.png", "cards": [ { "code": "ashling-st1", "name": "Cinder Plains", "face": "https://cdn.example.com/emberduel/ashling-st1.png" } ] } ] }, { "$schema": "https://table.place/pack.schema.json", "tbpp": 1, "specVersion": "1.5.0", "id": "ember-duel-mordrake", "name": "Mordrake", "scope": "player", "decks": [ { "slot": "avatar", "name": "Avatar", "back": "https://cdn.example.com/emberduel/back.png", "cards": [ { "code": "avatar-mordrake", "name": "Mordrake", "face": "https://cdn.example.com/emberduel/avatar-mordrake.png" } ] }, { "slot": "spellbook", "name": "Spellbook", "back": "https://cdn.example.com/emberduel/back.png", "cards": [ { "code": "mordrake-sp1", "name": "Grave Wind", "face": "https://cdn.example.com/emberduel/mordrake-sp1.png" }, { "code": "mordrake-sp2", "name": "Iron Vigil", "face": "https://cdn.example.com/emberduel/mordrake-sp2.png" } ] }, { "slot": "atlas", "name": "Atlas", "back": "https://cdn.example.com/emberduel/back.png", "cards": [ { "code": "mordrake-st1", "name": "Salt Marsh", "face": "https://cdn.example.com/emberduel/mordrake-st1.png" } ] } ] } ] } ``` Adding a board is one more entry in `packs`: ```json { "$schema": "https://table.place/pack.schema.json", "tbpp": 1, "specVersion": "1.5.0", "id": "ember-duel-arena", "name": "The Arena", "scope": "table", "decks": [], "overlays": [ { "imageUrl": "https://cdn.example.com/emberduel/arena.png", "ratio": 1.5, "scale": 12 } ] } ``` An overlay's `ratio` is the image's width ÷ height, and `scale` is how wide it lands in world units — the felt is 60 units along its long axis and 30 across, so `12` is a board occupying about 20% of that axis, centred. Every deck **must** carry `slot`, `name`, `back` and `cards`; `back` is required even on a slot the layout deals face-up, and an empty string is not a value. A card back is usually **not** shipped alongside card images — most card APIs serve faces only, often from a different host. One back image reused across every deck is fine. `gen:std52/back` is a fallback, but it is a **poker** back: correct for the smoke test above, visibly wrong under anything else. ### 4.4 The dry run — `POST https://api.table.place/v1/lobbies/validate` The same body, the same validation and composition, the same `4xx`s with the same messages — and then a `200` describing what would have been provisioned instead of a lobby. No lobby is created, nothing is stored, nothing touches the relay, and **nothing is spent against the lobbies-per-hour cap** in § 7. The per-IP request limit still applies. This is where iterating on a request body belongs: a real `POST` that turns out to be a `400` two packs later costs everyone a slice of the shared cap; a dry run costs nobody anything. ```json { "valid": true, "provisioned": false, "lobby": "brave-otter", "layout": "sorcery-2p", "seats": [ { "seat": 0, "pack": "ember-duel-ashling", "decks": [ { "slot": "avatar", "pack": "ember-duel-ashling", "cards": 1, "face_up": true, "shuffled": false, "order": [ "avatar-ashling" ] }, { "slot": "spellbook", "pack": "ember-duel-ashling", "cards": 2, "face_up": false, "shuffled": false, "order": [ "ashling-sp1", "ashling-sp2" ] }, { "slot": "atlas", "pack": "ember-duel-ashling", "cards": 1, "face_up": false, "shuffled": false, "order": [ "ashling-st1" ] } ] }, { "seat": 1, "pack": "ember-duel-mordrake", "decks": [ { "slot": "avatar", "pack": "ember-duel-mordrake", "cards": 1, "face_up": true, "shuffled": false, "order": [ "avatar-mordrake" ] }, { "slot": "spellbook", "pack": "ember-duel-mordrake", "cards": 2, "face_up": false, "shuffled": false, "order": [ "mordrake-sp1", "mordrake-sp2" ] }, { "slot": "atlas", "pack": "ember-duel-mordrake", "cards": 1, "face_up": false, "shuffled": false, "order": [ "mordrake-st1" ] } ] } ], "pieces": [], "overlays": 0, "snap_points": 0, "ttl_seconds": 3600 } ``` - `lobby` is the name that would have been used — rolled if you omitted `lobby`, echoed if you sent one. It is checked against existing lobbies (a taken name is the same `400 lobby_taken` the real POST gives) but **not reserved**: someone can take it between your dry run and your POST. Do not print it as an invite. - `seats[].decks` are the piles each seat would get: the slot, the `pack` those cards come from, the card count, which way it is dealt, whether it is shuffled on load, and `order` — the effective pile as card `code`s, top card first. It is your § 4.5 `order` echoed back when you stacked the deck (this is where "did my exact stack land?" is answered), the pack's declaration order when you did not, and `null` for a shuffled pile, whose order is rolled at load. The seat-level `pack` is the player-scoped pack that supplies that seat; a deck's own `pack` can differ from it, because a placement (§ 4.5) may deal a deck from any pack to any seat. - `pieces` are the tokens, pawns, counters, dice and bags that would be placed — every one of them from a `placements` entry of yours, since a layout places none (§ 4.5). Each carries the entity `id` this service allocates, the pack it comes from, its `kind` and `name`, the **effective** `position`, and a counter's or die's `value`. It is `[]` for a request with no piece placements. - `overlays` is how many board images would be on the felt, counted on the composed table. - `snap_points` is how many placement guides would be on it (§ 4.6) — a grid counts once, however many cells it covers. - `ttl_seconds` is the TTL after clamping (§ 7) — what `expires_at` would be computed from. - `provisioned` is always `false` here; there are no seat URLs because there is no lobby to open. **If you send `placements`, this is where you check them.** They go through the same validation as the real POST — the bounds, the exact `order`, the unplaced-content rule, all of § 4.5 — and `pieces[].position` and `decks[].order` are what the response tells you that your own request body does not: a `[x, z]` comes back with the height this service chose, a placement that omitted `position` comes back with the coordinates its pack authored, a piece placed twice **for the same seat** comes back as two entities suffixed `-0`/`-1` (the counter runs per seat, so the same piece placed once for each of two seats is two entities both ending in `-0`, told apart by the seat segment of the ids a player's claim will rename), and a stacked deck comes back with the pile that would actually land. A `200` whose `pieces` are not where you meant them is a request to fix before anyone opens a seat link. A `200` here means the same body is a `201` on `POST https://api.table.place/v1/lobbies`, minus two races (the name, the hourly cap) — and minus your art: a dry run validates JSON, and § 5 is still true. It cannot tell you whether a single face URL is reachable. ### 4.5 `placements` — exact positions, card orders, tokens and counters § 3 is a layout arranging your decks for you. `placements` is how you say something it does not know: put this counter at 20 life in front of seat 0, stack this deck in this order, lay a second board over there. (Dry-run it — § 4.4 reports every piece a placement of yours would put on the table.) **It is additive.** A request still names a `layout` and still inherits its arrangement. Each placement addresses one thing and overrides only what it mentions — a placement that sets nothing but `order` keeps the layout's position, rotation and facing for that deck. A request with no `placements` composes exactly as it did before this field existed, which is the promise behind `version: 1` staying `1`. **This vocabulary is ours, deliberately.** The field names below are defined by this API and versioned with `version`. They are *not* the tbps scenario format from § 7 of `https://table.place/llms.txt` — that format is `0.x`, explicitly unstable, and this service will not accept it precisely so that its churn cannot break you. What you send is translated internally. Do not infer field names from a scenario you have seen; use this table. **`"kind": "deck"`** — deals one of a pack’s decks to a seat. | Field | Required | Meaning | | --- | --- | --- | | `pack` | yes | a pack `id` from this request’s `packs` | | `slot` | yes | the `slot` of a deck in that pack | | `seat` | — | which seat owns the pile. Defaults to the pack’s own seat for a player-scoped pack; **required** for a table-scoped one, which has no seat to infer. | | `position` | — | where it lands. Required unless the layout already places this slot for this seat, in which case omitting it keeps the layout’s spot. | | `rotation` | — | yaw in degrees. Omitted keeps the layout’s facing (seat 1’s row is 180). | | `faceUp` | — | deal face-up. Omitted keeps the layout’s value for a slot it names, and is face-down otherwise — a deck’s own `isFaceUp` is still ignored (§ 4.1 rule 6). | | `order` | — | the exact pile, top card first, as card `code`s. Must list every card in the deck exactly once — a partial order would compose a deck missing cards, so it is a `400` rather than a quiet drop. | | `shuffle` | — | `true` shuffles this deck on every load. Per placement, so a stacked encounter deck can sit beside a shuffled draw deck. Cannot be combined with `order`. | **`"kind": "piece"`** — places one of a pack’s `pieces` — a token, pawn, counter, die or bag. | Field | Required | Meaning | | --- | --- | --- | | `pack` | yes | a pack `id` from this request’s `packs` | | `piece` | yes | index into that pack’s `pieces` — `0` is the first | | `seat` | — | as for a deck: the pack’s own seat, or required for a table-scoped pack | | `position` | — | omitted lands it where the **pack** authored it (`pieces[n].position`, mirrored for the far side). That is the one default here, and it is the pack author’s number, not this service’s. | | `rotation` | — | yaw in degrees | | `value` | — | a counter’s starting value (and a die’s starting face). Omitted starts a counter full, at its `maxValue`. Only those two kinds hold a value; `value` on a token or a pawn is a `400`, because the composer would drop it. | **`"kind": "overlay"`** — lays one of a pack’s `overlays` on the felt. | Field | Required | Meaning | | --- | --- | --- | | `pack` | yes | a pack `id` from this request’s `packs` | | `overlay` | yes | index into that pack’s `overlays` | | `position` | — | omitted leaves it centred, which is where a layout puts a table pack’s overlay anyway | | `rotation` | — | yaw in degrees | | `seat` | never | an overlay has no owner — it is keyed by pack, and nothing renames it when a player claims a seat. Sending one is a `400`. | #### The coordinate space World units, the same ones an overlay's `scale` is in. The felt is a **60 × 30** rectangle centred on the origin: - **x** runs along the long axis, -30 to 30. Positive x is to one side; the two seats' deck rows both sit at positive x. - **z** runs across, -15 to 15. Seat 0 sits at positive z and seat 1 at negative z, facing each other — so a token "in front of seat 0" has positive z. - **y** is height. The felt's top face is at **0.255**, and a placement may sit up to 5 units above it (0.255–5.255). **`position` is two numbers, `[x, z]`, and the height is chosen for you. Prefer that form.** The three-number form `[x, y, z]` exists for when you want the height too, and its trap is that `y: 0` is not "resting on the table" — it is *inside* it: `[4, 0, -2]` sinks a token halfway into the felt, while `[4, -2]` rests it on top. If you are converting coordinates from a system that writes `[x, y, z]`, dropping the `y` is almost always the right move. **Anything outside those bounds is a `400 placement_out_of_bounds`, never a `201`.** A token at `[900, 0, 0]` composes fine, seeds fine, and is invisible; that is the one failure a response could not tell you about, so it is refused with the bounds in the message. A piece placed with no `position` inherits the one its pack authored, and that is bounds-checked too. `rotation` is a **single yaw in degrees**, absolute in table space — not per-seat-relative, and not radians. `0` faces the way seat 0's decks face; `180` faces the way seat 1's do. Mind the unit across the two documents: rotations in § 4 of `https://table.place/llms.txt` are **radians**, correctly, for the format that document describes — the unit changes at this API's boundary. Send `3.14159` here hoping for a half-turn and you get a legal yaw of about 3°, a `201`, and a table that is subtly wrong; nothing can catch it, because both readings are in bounds. **Where the layout already put things is published, not guessed at.** `GET https://api.table.place/v1/layouts` carries a `geometry.deck_slots` array per layout: for every deck slot and seat, the `position` (`[x, y, z]`) and `rotation` (a yaw in degrees — this vocabulary's units, ready to paste into a placement) that slot's pile occupies. Read it before placing anything near a deck, because **nothing here refuses an overlap**: a token placed on a pile's coordinates lands on top of it — a card placed merely *near* a pile merges into it — with a `200`, a `201` and no warning. The geometry is how you avoid that without provisioning a lobby to look, and it is the same numbers the composer will use, read from the same function. One caveat: the published row assumes every slot is dealt; a pack that omits an *optional* slot closes the gap, shifting that seat's later piles one step inward. **Nothing you send is mirrored for the far seat.** `seat` says who *owns* the entity, not which half of the table to reflect it into: a piece placed at `[4, -3]` for seat 1 is at `[4, -3]`, and if you want a symmetric setup you send two placements with the signs you want (the § 4.5 example does exactly that). The only thing that mirrors is a pack's *own* authored piece position, when a placement omits `position` — that mirroring is the pack format's, described in `https://table.place/llms.txt`. #### A worked example: a counter per seat, a token, and a stacked deck The two seat packs from § 4.3, plus one table-scoped pack carrying a counter and a token, and the placements that put them on the felt. `seat` is explicit on each piece because the pieces come from a *table*-scoped pack, which has no seat of its own to infer. ```json { "version": 1, "layout": "sorcery-2p", "packs": [ { "$schema": "https://table.place/pack.schema.json", "tbpp": 1, "specVersion": "1.5.0", "id": "ember-duel-ashling", "name": "Ashling", "scope": "player", "decks": [ { "slot": "avatar", "name": "Avatar", "back": "https://cdn.example.com/emberduel/back.png", "cards": [ { "code": "avatar-ashling", "name": "Ashling", "face": "https://cdn.example.com/emberduel/avatar-ashling.png" } ] }, { "slot": "spellbook", "name": "Spellbook", "back": "https://cdn.example.com/emberduel/back.png", "cards": [ { "code": "ashling-sp1", "name": "Ember Bolt", "face": "https://cdn.example.com/emberduel/ashling-sp1.png" }, { "code": "ashling-sp2", "name": "Ashen Ward", "face": "https://cdn.example.com/emberduel/ashling-sp2.png" } ] }, { "slot": "atlas", "name": "Atlas", "back": "https://cdn.example.com/emberduel/back.png", "cards": [ { "code": "ashling-st1", "name": "Cinder Plains", "face": "https://cdn.example.com/emberduel/ashling-st1.png" } ] } ] }, { "$schema": "https://table.place/pack.schema.json", "tbpp": 1, "specVersion": "1.5.0", "id": "ember-duel-mordrake", "name": "Mordrake", "scope": "player", "decks": [ { "slot": "avatar", "name": "Avatar", "back": "https://cdn.example.com/emberduel/back.png", "cards": [ { "code": "avatar-mordrake", "name": "Mordrake", "face": "https://cdn.example.com/emberduel/avatar-mordrake.png" } ] }, { "slot": "spellbook", "name": "Spellbook", "back": "https://cdn.example.com/emberduel/back.png", "cards": [ { "code": "mordrake-sp1", "name": "Grave Wind", "face": "https://cdn.example.com/emberduel/mordrake-sp1.png" }, { "code": "mordrake-sp2", "name": "Iron Vigil", "face": "https://cdn.example.com/emberduel/mordrake-sp2.png" } ] }, { "slot": "atlas", "name": "Atlas", "back": "https://cdn.example.com/emberduel/back.png", "cards": [ { "code": "mordrake-st1", "name": "Salt Marsh", "face": "https://cdn.example.com/emberduel/mordrake-st1.png" } ] } ] }, { "$schema": "https://table.place/pack.schema.json", "tbpp": 1, "specVersion": "1.5.0", "id": "ember-duel-tokens", "name": "Ember Duel — tokens", "scope": "table", "decks": [], "pieces": [ { "kind": "counter", "name": "Life", "color": "#c0392b", "maxValue": 20, "position": [ 0, 6 ] }, { "kind": "token", "name": "Ember", "imageUrl": "https://cdn.example.com/emberduel/ember.png", "position": [ 0, 5 ] } ] } ], "placements": [ { "kind": "piece", "pack": "ember-duel-tokens", "piece": 0, "seat": 0, "position": [ 6, 5 ], "value": 20 }, { "kind": "piece", "pack": "ember-duel-tokens", "piece": 0, "seat": 1, "position": [ 6, -5 ], "value": 20 }, { "kind": "piece", "pack": "ember-duel-tokens", "piece": 1, "seat": 0, "position": [ 4, 3 ] }, { "kind": "deck", "pack": "ember-duel-ashling", "slot": "spellbook", "order": [ "ashling-sp2", "ashling-sp1" ], "faceUp": false } ] } ``` That composes: seat 0's spellbook restacked with `ashling-sp2` on top (position and facing still the layout's, because the placement did not say), a life counter at 20 in front of each seat, and one ember token beside seat 0's row. The two counters come from **one** pack piece placed twice — repeating a `piece` is how you get several of something, and each becomes its own entity. #### What a placement cannot do - **Change content.** A placement addresses what your packs already carry; it cannot add a card, rename a piece or point at an image. Content is the pack's, and `order` must therefore list every card in the deck exactly once. - **Place content that did not travel.** `pack` must be an `id` in this request's `packs` (`400 unknown_placement_target`). - **Place the same entity twice.** Two placements for one seat's `spellbook` is `400 duplicate_placement` — the second would silently replace the first. Two placements of the same *piece* are two pieces, and fine. - **Own an overlay by a seat.** Overlays are table-scoped; `seat` on one is a `400`. - **Survive a partial `order`.** Listing some of a deck's cards is `400`, not a deck with the rest missing. - **Carry a field that is not in the tables above.** Unknown fields are **refused, not ignored** — a typo'd `positon` would otherwise be a token resting somewhere you did not ask for, with a `201` on it. This is also what happens if you reach for a tbps name (`isFaceUp`, `shuffleOnLoad`, `content`): the error names the field and lists the ones this kind takes. - **Displace another pack's deck.** A placement that would deal into the pile the layout already fills from that seat's own pack is `400 duplicate_placement` — one of the two decks would vanish, and neither reading is the one to guess at. ### 4.6 `snapPoints` — snap grids and placement guides A snap point is a spot on the table that a dropped card or piece finishes exactly on: a battle-map grid, a discard spot, a ledge. They are guides for **play**, not initial positions — `placements` (§ 4.5) says where things start; `snapPoints` says where they land when a player moves them. A request without any composes exactly as before the field existed. Unlike `placements`, the shape here **is** the scenario file's own snap point shape, on purpose: it is additive-stable under the format's own rules, there is nothing unstable to firewall away, and it means `GET /v1/lobbies/:id/scenario` hands back exactly the array you sent (§ 6.1). Entries are validated by the pinned scenario parser — the same one the table imports files with. | Field | Required | Meaning | | --- | --- | --- | | `position` | yes | table-space `[x, z]`, the same units and bounds as § 4.5. For a grid, this is the grid’s **centre**. | | `y` | — | elevation: the local floor whatever lands here rests on, replacing the felt in the height arithmetic. Omitted means the felt. A card dropped on a card on an elevated point still stacks. | | `rotation` | — | yaw in degrees a caught drop turns to. Omitted leaves the entity’s own rotation alone. On a grid this is the lattice’s yaw; the landing’s own yaw rounds by `yawStep`. | | `radius` | — | catch radius in world units. Omitted takes the table’s default (0.9). | | `kind` | — | `"grid"` turns the entry into a lattice of square cells — a drop anywhere over it pulls to the nearest cell centre. Omitted (or `"point"`) is the discrete spot it always was. | | `pitch` | grids | cell size in world units. | | `cols` | grids | extent in cells across x. | | `rows` | grids | extent in cells across z. | | `yawStep` | — | grids only — degrees a landing’s yaw rounds to, from the grid’s own yaw (default 90). | A grid is **one entry** however many cells it covers — an 8×6 dungeon floor is not 48 snap points, and the 200-entry cap is counted on entries. Two entries added to the § 4.5 request give it a floor and a ledge: ```json [ { "position": [ 0, 0 ], "kind": "grid", "pitch": 1.5, "cols": 8, "rows": 6 }, { "position": [ 7, 4 ], "y": 1, "rotation": 45, "radius": 1.2 } ] ``` A malformed entry is `400 invalid_snap_points` carrying the parser's own complaint (`snapPoints[0].pitch must be a positive cell size…`), and — as everywhere in this request — an unknown field on an entry is refused rather than ignored. ## 5. Card faces are fetched by the player's browser This service never loads an image. It validates JSON, composes a table and pushes it to the lobby relay; every `face`, `back` and `imageUrl` in your packs is resolved later, by each player's browser, from wherever the ref points. So a face ref must be **publicly reachable from an arbitrary browser**: - a plain `https://` URL that will still work tomorrow, for someone else, with no session behind it. A query string is fine — a cache-busting `?1783903008` on a public CDN is not a problem. What fails is a URL that expires, is signed for one viewer, or needs a cookie or header; - reachable from the public internet — `http://localhost:…`, a private IP or a VPN-only host works on your machine and nowhere else; - CORS-readable and hotlinkable — a host that blocks cross-origin requests or referrer-checks its images serves your own site fine and this table not at all; - **never** inlined image data. A `data:` ref anywhere in a pack is refused outright (`400 invalid_pack`): refs are carried **by value** into synced multiplayer state, and a seed message over the relay's 1 MiB read limit is dropped along with the connection carrying it — a lobby broken at birth. A pack full of them would blow the 2097152 bytes (2.0 MiB) body cap anyway. **An unreachable face is not an error anywhere in this API.** You get a `201`, working seat links, and blank white cards. **Format and shape.** Any image a browser can decode: PNG, JPEG and WebP all work, and nothing here inspects the bytes or the extension. A card is a portrait rectangle — `CARD_WIDTH` and `CARD_HEIGHT` in the constants table of § 4 of `https://table.place/llms.txt` are the world units, and matching that ratio is the safe move rather than assuming a mismatch is cropped for you. Resolution is your call: nothing about the image reaches this API at all (§ 5.2). **Hosts that work.** Anything that serves a public image with permissive CORS: GitHub raw (`raw.githubusercontent.com`), object storage with public read (S3, R2, GCS), a static site host, most image CDNs. If you want a concrete URL to test the pipeline with before your own hosting exists, `https://deckofcardsapi.com/static/img/AS.png` (and `KH.png`, `QS.png`, … — the same codes the § 4.2 smoke test uses) passed the § 5.1 check when this was written; it is a third party this service does not control, so treat it as a five-minute test fixture, never a production host. **Hosts that do not:** Google Drive and Dropbox "share" links (they serve an HTML viewer page, not the image), anything behind a login or a signed expiring URL, private IPs and `localhost`, and any host that referrer-checks to block hotlinking. When in doubt, run the check below — it is the whole test. ### 5.1 Checking a face properly Opening the URL in a browser tab proves the image exists. It does **not** prove the browser can use it on a table: a direct navigation sends no `Origin` header, so it never exercises CORS at all. Send one: ```sh curl -sS -o /dev/null -D- -H 'Origin: https://table.place' 'https://your.cdn/card.png' ``` You want `HTTP/… 200`, an `image/…` content type, and `access-control-allow-origin` present (`*` or your table's origin). A host that returns the image but no such header will render blank in the table and fine in your tab — which is exactly how this is usually misdiagnosed. A green sweep proves the URLs are **reachable**, not that they are the **right cards**. If you built the pack by looking cards up in someone's API by name, a near-miss — the reprint, the other half of a split card, the wrong set — passes this check perfectly and lands a real image of the wrong card on the table. Print the names alongside the URLs and read them once. Sweeping a whole pack before you POST it, which is worth doing once per art source. One line per ref: status, content type, and whether CORS was answered. ```sh jq -r '.decks[].cards[].face, .decks[].back' pack.tbpp.json | sort -u | while read -r ref; do printf '%s ' "$ref" curl -sS -o /dev/null -D- -H 'Origin: https://table.place' "$ref" 2>&1 | tr -d '\r' | tr 'A-Z' 'a-z' | awk '/^http\//{s=$2} /^content-type:/{c=$2} /^access-control-allow-origin:/{a=$2} END{print s, c, (a=="" ? "NO-CORS-HEADER" : "cors-ok")}' done ``` (`while read` rather than `xargs -I`: BSD `xargs` refuses a substituted line this long, so the tidier version fails on macOS and works on Linux.) ### 5.2 The body cap is about JSON, not pixels Faces are refs, so your images never travel in the request. A full 500-card pack of plain URL faces is about **49 KiB** — nowhere near the 2097152 bytes (2.0 MiB) body cap, however large the images themselves are. The card-count cap binds first, always. Only inlined image data can blow the byte cap, and that is the one thing you must not send. The three ref schemes (`https://`, `gen:`, `sheet:`) are specified in § 5 of `https://table.place/llms.txt`. This service never resolves one, but it does refuse the two mistakes the ref *string alone* proves (`400 invalid_pack`): a `data:` ref, and a `gen:` ref outside the `gen:std52/…` grammar the client can actually draw — an invented namespace (`gen:mtg/…`) renders as a broken image, never as generated art. Everything else passes through untouched, exactly as sent. ## 6. What comes back `201`, with one URL per seat: ```json { "lobby": "brave-otter", "lobby_url": "https://table.place/play?lobby=brave-otter", "seats": [ { "seat": 0, "url": "https://table.place/play?lobby=brave-otter&seat=0" }, { "seat": 1, "url": "https://table.place/play?lobby=brave-otter&seat=1" } ], "expires_at": "2026-07-26T21:15:00.000Z" } ``` - `lobby_url` opens the table without claiming a seat — fine for a spectator or a screenshot. - `seats[].url` is the link to hand to a player; `seat` matches the index of the player-scoped pack that supplied that seat's decks. - `expires_at` is when the lobby stops being addressable (§ 7). - Seat links point at `https://table.place/play` on this deployment. The links are the whole delivery mechanism: there is no lobby list, no lookup by name, and no way to recover a lobby id you did not keep. Store what you got back. **What a `201` does and does not promise.** It means the table was composed and the relay took the seed — a relay that refused the connection, or dropped it mid-seed, is a `502 lobby_unreachable` and not a lobby link. So you do not need a separate "is it seeded?" check; there isn't one, and that is why. What a `201` says nothing about is your art (§ 5): the images are resolved later, by a browser, and no status code here can see them. ### 6.1 `GET https://api.table.place/v1/lobbies/:id/scenario` — the re-seed fallback Everything needed to rebuild the table without the request that made it: the composed scenario, the packs it references (they arrived in a request body and exist nowhere else), and the `placements` you sent, verbatim: | Field | Type | Meaning | | --- | --- | --- | | `lobby` | string | the lobby id | | `layout` | string | the layout it was provisioned with | | `seats` | number | **the seat count** — not the array of seat URLs the 201 carries under this name | | `expires_at` | string | ISO 8601, as in the 201 | | `scenario` | object | the composed scenario document | | `packs` | array | the packs it references, as whole tbpp documents — the same objects you POSTed | | `placements` | array | your § 4.5 `placements`, **exactly as you sent them** — `[]` if you sent none. Not the scenario’s internal ones | | `snapPoints` | array | your § 4.6 `snapPoints`, exactly as you sent them — the same echo, one field over. `[]` if you sent none | Two things it is for: - **A `502 lobby_unreachable`.** The lobby was created but the relay refused the seed. The error's `details.scenario_url` points here; the composition is done, and the packs are here rather than lost with your request. - **Rebuilding after the table is wiped.** Lobby state lives in memory on the relay and does not survive its restart. The manifest here does, until `expires_at`. It is `404 not_found` for an unknown id and `410 expired` past the TTL. The `scenario` field is described by § 7 of `https://table.place/llms.txt` and is explicitly unstable — read it, store it, but do not build a generator against its internals. In particular, the `placements` **inside the scenario** are not § 4.5's: they are the internal format, translated, and their field names move without notice. The top-level `placements` beside it is the same information in the one shape this API accepts — yours, echoed rather than translated back, precisely so that the two can never drift apart. So the whole recovery is one assembly: `{ "version": 1, "layout": , "packs": , "placements": , "snapPoints": }`, each field straight from this response, POSTed to `/v1/lobbies` — a `201` and a fresh lobby carrying the same table. (`lobby` names are single-use while live, so let a new one be rolled.) The top-level `snapPoints` is the same verbatim echo as `placements`; the copy inside `scenario.snapPoints` is the parsed form — same shape (§ 4.6), canonical key order — and either re-POSTs. Keeping your original request body still works and always will; it is just no longer the only thing that reproduces the table. > **`packs` round-trips.** Each entry is a whole tbpp document — `$schema`, `tbpp` and `specVersion` included. Note it is a re-serialization and not your bytes back: anything the pack format does not define was never stored, and key order is the serializer's. `placements` round-trips harder: it **is** your bytes back — stored verbatim at provisioning, never reconstructed from the scenario — so even a placement field newer than this paragraph survives it. ## 7. Limits Nothing here is per-caller — there are no accounts to attach a quota to. The caps are the whole abuse story. | Limit | Value | Exceeded | | --- | --- | --- | | request body | 2097152 bytes (2.0 MiB) | `413 payload_too_large` | | cards per pack | 500 | `413 payload_too_large` | | packs per request | 8 | `413 payload_too_large` | | `placements` per request | 100 | `413 payload_too_large` | | `snapPoints` per request | 200 | `413 payload_too_large` | | requests per minute, per IP | 30 | `429 rate_limited` with `Retry-After` | | lobbies per hour, service-wide | 200 | `429 rate_limited` — everyone shares this one. A dry run (§ 4.4) never spends it | | `ttlSeconds` | min 60 (1m), max 86400 (24h), default 3600 (1h) | clamped, never rejected | | `lobby` length | 64 characters | `400 invalid_lobby` | | an **empty** lobby | collected after ~15 minutes | the relay reaps it — this is not the TTL, and it is usually what ends a lobby first | **Iterating does not have to spend the shared cap.** The lobbies-per-hour cap bounds real lobbies on the relay; `POST /v1/lobbies/validate` (§ 4.4) creates none and is deliberately outside it. Debugging a request body against the real POST spends a cap every other integration's players are drawing on — the dry run exists so you never have to. **`expires_at` is a ceiling, not a promise.** Two clocks end a lobby and the shorter one usually wins: - `ttlSeconds` — what you asked for, clamped, and what `expires_at` reports. Up to 86400 (24h). - **the relay's empty-lobby reap, about 15 minutes.** A lobby that nobody has joined, or that everyone has left, is collected regardless of `expires_at`. Nothing warns you, and the seat links simply stop working. So a `ttlSeconds` of 86400 does not buy you a day-old invite link. **Provision when your players are ready**, not hours ahead — and if you are handing links out in advance, expect to provision again. Provisioning again is cheap and gives a fresh lobby; there is no "extend". ## 8. Errors Every failure is the same shape, and the message is the diagnosis rather than a category — it names the pack index, the missing slot, the count that was wrong. Read `message`; switch on `error`. ```json { "error": "missing_deck_slot", "message": "Pack 'ember-duel-ashling' (seat 0) is missing deck slot 'atlas', which layout 'sorcery-2p' requires. It has: 'avatar', 'spellbook'.", "details": { "pack": "ember-duel-ashling", "seat": 0, "missing": ["atlas"] } } ``` `details` is present only where there is something machine-readable to say. | Status | Code | When | | --- | --- | --- | | 400 | `invalid_body` | the body is not a JSON object. | | 400 | `invalid_json` | the body did not parse; the message carries the parser error. | | 400 | `empty_body` | the body was empty. | | 400 | `unsupported_version` | `version` is absent or is not the current request version. | | 400 | `unknown_field` | the body carries a top-level field this API does not define. Refused rather than ignored — a typo’d `placments` would otherwise be a lobby quietly missing what you asked for. `details.accepts` lists the fields a request takes. | | 400 | `invalid_layout` | `layout` is missing or not a string. | | 400 | `unknown_layout` | no such layout. `details.known_layouts` lists the ids that exist. | | 400 | `invalid_packs` | `packs` is absent, not an array, or empty. | | 400 | `invalid_pack` | one entry is not a valid tbpp pack. The message names the index and the parser complaint; `details.pack_index` repeats the index. Also raised for the two ref mistakes decidable without a fetch (§ 5): a `data:` ref anywhere in a pack — inlined image data is carried by value into synced multiplayer state and breaks the seed — and a `gen:` ref in a namespace the client cannot draw, which renders as a broken image. Those name the pack, the deck, the card `code` and the offending ref; `details.ref` repeats it. | | 400 | `duplicate_pack_id` | two packs share an `id`. Placements address content by pack id, so duplicates would put one seat’s cards on both seats. | | 400 | `seat_count_mismatch` | the number of player-scoped packs is not the layout’s seat count, or `players.max` disagrees with the layout. | | 400 | `missing_deck_slot` | a seat’s pack has no deck in a slot the layout requires. The message names the slot and lists the ones the pack does have. | | 400 | `unsupported_content` | a pack carries content that neither the layout nor your `placements` place, so composing it would drop it: an unplaced `piece` of any kind (bags among them), a deck in a slot the layout does not name, `overlays` on a player-scoped pack, `decks` on a table-scoped pack, or more than one *unplaced* overlay across the table packs. The message names the pack, the content and a `placements` entry that would carry it; `details.unsupported` says which of the five it was. Refused rather than dropped — a `201` for a lobby missing what you sent teaches you nothing. | | 400 | `invalid_placements` | `placements` is not an array, or one entry is malformed: an unknown `kind`, a field of the wrong type, a `seat` the layout does not have, an overlay carrying a `seat`, an `order` that is not the deck’s cards exactly once, `order` together with `shuffle`, or a placement that needs a `position` and has none. The message names the entry as `placements[i]` and the field; `details.placement` repeats the index. | | 400 | `unknown_placement_target` | a placement addresses content that is not in the request: a `pack` id that did not travel, a deck `slot` the pack does not have, a `piece`/`overlay` index it does not have, or an `order` naming a card `code` the deck lacks. `details` lists what the pack does have. | | 400 | `placement_out_of_bounds` | a placement’s position is off the felt, below its surface, or too far above it — including a position inherited from a pack’s own authored piece coordinates. The message carries the bounds in world units; `details.bounds` repeats them. Refused because a composed entity nobody can see is indistinguishable from one that never arrived. | | 400 | `duplicate_placement` | two placements address the same entity — the same seat and deck `slot`, or the same pack and `overlay` index — so the second would silently replace the first. `details.conflicts_with` is the index of the other one. Also raised when a deck placement would take the place of the pile the layout already fills from that seat’s own pack, which would drop that seat’s deck. Two placements of the same *piece* are two pieces and are fine. | | 400 | `invalid_snap_points` | `snapPoints` is not an array, an entry carries a field snap points do not have, or an entry fails the pinned scenario parser — a grid without `pitch`, a `position` that is not `[x, z]`. The message carries the parser’s own complaint, naming the entry as `snapPoints[i]`. | | 400 | `invalid_players` | `players` is present but is not `{"max": }`. | | 400 | `invalid_lobby` | `lobby` is empty, too long, or not lowercase words/digits/hyphens — it goes straight into the invite URL. | | 400 | `invalid_ttl` | `ttlSeconds` is present but not a finite number. | | 400 | `lobby_taken` | the requested `lobby` already exists. Choose another, or omit it and one is rolled for you. | | 404 | `not_found` | no such route, or no such lobby. | | 410 | `expired` | the lobby’s TTL has passed. Provision a new one; nothing is recoverable. | | 413 | `payload_too_large` | the body, a pack’s card count, or the number of packs is over a cap. The message names the cap and the value that exceeded it. | | 429 | `rate_limited` | the per-IP request rate or the service-wide lobby rate is exceeded. `Retry-After` and `details.retry_after_seconds` say how long to wait. | | 500 | `internal_error` | a bug here. Retrying is reasonable; the body carries nothing to act on. | | 502 | `lobby_unreachable` | the lobby was created but the relay would not take the seed. `details.scenario_url` is the composed scenario — the lobby exists and can be re-seeded rather than re-composed. | | 503 | `no_lobby_name` | eight random lobby names in a row were taken. Send an explicit `lobby`. | A `502 lobby_unreachable` is the one failure that is not "your request was wrong" and not "retry the same thing": the lobby exists, and § 6.1 is how you finish. ## 9. CORS `Access-Control-Allow-Origin: *` on every route, methods `GET, POST, OPTIONS`, request header `content-type`, preflight cached for a day. This is deliberate. The callers are browsers on static sites — a deck builder on GitHub Pages has no server to proxy through — so a same-origin-only API would be unusable to exactly the people it exists for. There is no credential to leak: there are no accounts, no cookies and no keys, and everything this API does is done by anyone who can reach it. You can therefore POST straight from a static page. You can also POST from a server; nothing changes. ## 10. What this API does not do Stated plainly, because the alternative is discovering it after building on the assumption. **You cannot send a tbps scenario.** § 4.5 is a placement vocabulary, and it is this API's own — its field names, its version, its `0`-based indices. The scenario format in § 7 of `https://table.place/llms.txt` is not accepted at any endpoint, and that is a firewall rather than an omission: it is `0.x` and may change without notice, so publishing it as this contract would make every internal shape change a breaking change for you, with no migration path. Send § 4.5's fields; they are translated here. **A layout places `deck` and `overlay` content and nothing else.** Every seat's named deck slots, and one centred overlay from a table-scoped pack. Anything else in your packs reaches the table **only** because a placement of yours says where it goes: a `pieces` entry of any kind, a deck in a slot the layout does not name, an overlay on a player-scoped pack, a deck on a table-scoped pack, a second overlay. **And content nothing places is refused, not dropped.** If neither the layout nor your `placements` carry a piece of pack content, the request is a `400 unsupported_content` naming the pack, the content and a `placements` entry that would carry it. A `201` for a lobby quietly missing what you sent would be the one failure mode you could not diagnose from the response, so it does not happen. **No default position is invented for a piece.** Where a token goes is your decision — that is what `placements` is for — and a position guessed here would have become a compatibility constraint the day after it shipped. A placement that omits `position` falls back to the position **your pack** authored, never to one of ours. **Also absent:** no state or score is pushed back to you, no webhooks, no lobby listing or search, no way to update or delete a lobby once created (they expire), no deck-sync back to your site, and no asset hosting. ## 11. Before you POST 1. `GET https://api.table.place/v1/layouts` and pick a layout id. 2. Build the body: one pack per seat with `"scope": "player"`, in seat order, each carrying every required slot for that layout; every pack `id` distinct; packs inline in `packs`, not URLs; `version: 1` present; only the fields § 4 lists at the top level (§ 4.1 rule 9); under the caps in § 7 — ≤ 8 packs, ≤ 500 cards each, ≤ 100 placements, ≤ 200 snap points. (The byte cap will not be your problem unless you inlined an image, § 5.2.) 3. If your packs carry `pieces`, a deck in a slot the layout does not name, or a second overlay: one `placements` entry each (§ 4.5), with positions inside the felt's 60 × 30 and clear of the layout's own piles — the `geometry` in `GET https://api.table.place/v1/layouts` says where those sit, and nothing refuses a placement on top of one. Nothing unplaced gets through, and nothing unplaced is dropped. 4. **Dry-run it until it is a `200`**: `POST https://api.table.place/v1/lobbies/validate` with that body (§ 4.4). It fails with exactly the errors the real POST would — every rule in steps 2 and 3 included, placements and their bounds among them — and it never spends the shared lobby cap. Read its `pieces` back: it is the only confirmation you get that a placement landed where you meant, before players do. 5. Every `face`, `back` and `imageUrl` checked **with an `Origin` header** (§ 5.1), not by opening it in a tab. Public internet, HTTPS, `access-control-allow-origin` present. This is the one check no dry run can do for you. 6. `POST https://api.table.place/v1/lobbies` — once, last, because it is the one step the dry run cannot stand in for: only a real POST proves the relay half, and it is what spends the shared lobby cap (§ 7). On a first integration, spend it on § 4.2's smoke body and open a seat link — that separates "my request is wrong" from "my art is unreachable" before you have both problems at the same time. Keep the response: the seat URLs are the only handle on the lobby. (Your request body is no longer load-bearing: § 6.1 hands back the packs and your placements in a re-POSTable shape — but you need the lobby id to ask it.) If it fails, `message` says what to change. If it succeeds and the table looks blank, re-read § 5.