# Game Table > Game Table is a platform for multiplayer board and card games at a real table: a touchscreen board lying flat, each player's phone as their private deck, AI players, and remote players who join through an invite. A game is a folder with a manifest, pure deterministic rules in TypeScript, and optional plain-JavaScript UI modules for the board and the phones; the platform does everything else (lobby, seats, players joining, leaving and being exchanged, AI scheduling, pauses, logs, replay, statistics, remote play). **Installing it locally** (a table on one machine, phones on the same network): follow the runbook https://games.skylar.technology/docs/local-docker.md step by step. Docker is all it needs: no cloud service or console, no DNS entry, no HTTPS, nothing from games.skylar.technology. Games from other repositories, private ones included, are added by cloning them (runbook Part B); no token or console is involved. Read the developer guide before writing or changing a game. It is the contract between games and the platform, written for language models as much as for people. The platform's code is the final word: `packages/sdk/src/index.ts` (the rules API) and `packages/protocol/src/index.ts` (the manifest schema and lobby types) in the public repository https://github.com/klaushofrichter/games-service. For a complete game repository of its own to copy (three games, tests against the platform, CI), see the public example https://github.com/klaushofrichter/extra-games. Key rules for every game: rules are pure and deterministic (randomness only from `ctx.random()`, time only from `ctx.now()`, never mutate the given state); a seat's view never contains another seat's secrets; a running game always keeps all its players, and the platform owns joining, leaving, away phones and exchanging players (a game only reacts in `onSeatChanged`); AI players read only their seat's view; a game's version changes with every change in its folder. ## Developer guide - [Writing a game for Game Table](https://games.skylar.technology/docs/game-development.md): the full contract, with a checklist (the same file in the repository: https://raw.githubusercontent.com/klaushofrichter/games-service/main/docs/developers/game-development.md) - [Everything in one file](https://games.skylar.technology/llms-full.txt): this index followed by the whole guide ## API reference (source) - [Rules SDK](https://raw.githubusercontent.com/klaushofrichter/games-service/main/packages/sdk/src/index.ts): `defineGame`, `GameDefinition` with every hook, `GameContext`, `SeatInfo`, `count`, `list`, `mostAward`, `leastAward` - [Cards helpers](https://raw.githubusercontent.com/klaushofrichter/games-service/main/packages/sdk/src/cards.ts): deck, seeded shuffle, poker hand evaluation - [Manifest schema and lobby types](https://raw.githubusercontent.com/klaushofrichter/games-service/main/packages/protocol/src/index.ts): `manifestSchema`, `GameSummary`, `GameAward`, `PlayerInfo`, `LobbyState` - [Board kit for UI modules](https://raw.githubusercontent.com/klaushofrichter/games-service/main/apps/table-host/public/shared/board-kit.js): seat plates, player colours, rotation, countdowns, sounds, styles - [Game log format](https://raw.githubusercontent.com/klaushofrichter/games-service/main/apps/table-host/src/log-format.md): records, move lists, replay ## Example games - [Coin Flip](https://github.com/klaushofrichter/games-service/tree/main/games/coin-flip): the smallest complete game (simultaneous guesses, timers, statistics, players joining mid-game, board and phone UIs, rules for players) - [Pachisi](https://github.com/klaushofrichter/games-service/tree/main/games/pachisi): turns, dice, captures, moves made by touching the board, `describe` for the Game Viewer - [Hold'em](https://github.com/klaushofrichter/games-service/tree/main/games/holdem): private cards, AI styles, awards, a skippable wait, sounds - [Roulette](https://github.com/klaushofrichter/games-service/tree/main/games/roulette): simultaneous betting, AI styles - [Example game repository](https://github.com/klaushofrichter/extra-games): Chess, Liar's Dice and Backgammon in a repository of their own (public), tested against games-service with CI; the layout to copy for a new game repository ## Running it - [Run Game Table locally with Docker](https://games.skylar.technology/docs/local-docker.md): the runbook for a local installation, written for agents: the Table Host with the built-in games on one machine, phones on the LAN, no cloud service, DNS or HTTPS; games from other repositories, private ones by cloning; optionally a local cloud service (the same file in the repository: https://raw.githubusercontent.com/klaushofrichter/games-service/main/docs/runbooks/local-docker.md) ## Testing - [Rules test harness](https://raw.githubusercontent.com/klaushofrichter/games-service/main/tests/harness.ts): run a game on the real runtime with virtual time, check exact replay - [Host test helpers](https://raw.githubusercontent.com/klaushofrichter/games-service/main/tests/helpers.ts): a real Table Host with fake phones ## Optional - [Platform README](https://raw.githubusercontent.com/klaushofrichter/games-service/main/README.md): running a table, the board control API, extra-game repositories, remote play, logs - [Decision log](https://raw.githubusercontent.com/klaushofrichter/games-service/main/docs/decisions.md): why the platform behaves as it does --- # Writing a game for Game Table This is the contract between a game and the Game Table platform: what a game provides, what the platform does for it, and the rules both sides keep. It is written for people and for language models that write games. If something here and the code disagree, the code in this repository is right and this page is wrong: say so in an issue or a pull request. - **Platform:** this repository, `games-service` (public): the Table Host that runs games, the board and phone pages, the SDK, the cloud service at `https://games.skylar.technology`. - **Short index for language models:** [`/llms.txt`](../../llms.txt), also served at `https://games.skylar.technology/llms.txt`; this whole page at `https://games.skylar.technology/llms-full.txt`. - **Example game repository:** [`klaushofrichter/extra-games`](https://github.com/klaushofrichter/extra-games) (public): Chess, Liar's Dice and Backgammon in a repository of their own, with the test setup and CI against this repository (section 15). - **Reference games in this repository:** `games/coin-flip` (the smallest complete game: simultaneous turns, timers, statistics, players joining mid-game), `games/pachisi` (turns, dice, moves made by touching the board), `games/holdem` (private cards, AI styles, a skippable wait, sounds), `games/roulette` (simultaneous betting, AI styles). Read one of them next to this page. ## Contents 1. [The table in one minute](#1-the-table-in-one-minute) 2. [Principles](#2-principles) 3. [A game's folder](#3-a-games-folder) 4. [The manifest](#4-the-manifest) 5. [The rules (`rules.ts`)](#5-the-rules-rulests) 6. [Players and seats: what the platform does, and what a game hears](#6-players-and-seats) 7. [AI players](#7-ai-players) 8. [The end of a game: summary, table, awards](#8-the-end-of-a-game) 9. [The board UI (`board.js`)](#9-the-board-ui-boardjs) 10. [The phone UI (`deck.js`)](#10-the-phone-ui-deckjs) 11. [Rules for players (`how-to-play.md`)](#11-rules-for-players-how-to-playmd) 12. [Logs, replay and the Game Viewer](#12-logs-replay-and-the-game-viewer) 13. [Versions](#13-versions) 14. [Testing](#14-testing) 15. [A game repository of your own](#15-a-game-repository-of-your-own) 16. [Checklist before a pull request](#16-checklist-before-a-pull-request) ## 1. The table in one minute - **The board** is a touchscreen lying flat on a table (1920 × 1080 logical pixels), seen from all sides. It shows the lobby and, during a game, the game's public view. - **The decks** are the players' phones. Each shows that player's private view and sends their moves. - **The Table Host** (a PC under the table) runs the lobby and the game's rules, keeps the log, and serves the board and phone pages over the local network. - **Remote players** join through an invite from the cloud service. They get the same board (turned towards their own seat) and the same phone page, through a peer-to-peer channel to the table. **Watchers** get the board only: no seat, no phone page, not in `info.players`; a game needs to do nothing for them, beyond keeping secrets out of `boardView`. - **AI players** sit in seats like people. The platform asks the game's `ai()` for their moves. - Up to 8 seats, on the four edges of the table (`north`, `east`, `south`, `west`). Seat layouts have 2, 4, 6 or 8 seats. A game is **rules** (pure functions on a state, run by the host) plus **two UIs** (the board and the phone, plain browser JavaScript modules that draw what the rules' views say). Everything else is the platform's: lobby, seats, joining, names and colours, phones going away and coming back, exchanging players, pausing, AI scheduling, logs, replay, statistics, rules for players, settings and remote play. ## 2. Principles 1. **One way for the same thing, in every game.** The platform owns everything about players: joining, leaving, a phone that goes away, replacing a player, names, colours, turning the board, pausing. A game never invents its own timeout, its own "replace a player" or its own lobby. It reacts to the hooks below. 2. **A running game keeps all its players.** The number of seats never changes during a game; a seat is never emptied. Who plays a seat can change (the board *exchanges* a player for another person or an AI); the game is told. 3. **Rules are deterministic and pure.** Same state, same inputs, same random numbers: same result. Every game can be replayed exactly from its log, and the tests check it. 4. **Views are honest and private.** A seat's view shows only what that player may know. The board's view is public. Phones never get another seat's secrets, not even hidden in data. 5. **The rules decide, the UIs only draw.** A UI never decides what is legal; it shows what the view says and sends intents. The rules check every intent again. 6. **People first at a real table.** The board is read from every side and from a distance; phones are small. Short texts, large touch targets, nothing important only by colour. ## 3. A game's folder A game is one folder; the folder name is the game's id (`a-z`, `0-9`, `-`). | File | Required | What it is | | --- | --- | --- | | `manifest.json` | yes | The game's facts and options, checked against the schema in `packages/protocol/src/index.ts` (`manifestSchema`). | | `rules.ts` | yes | `export default defineGame({ … })`: the rules. Runs on the Table Host only; never sent to browsers. | | `board.js` | no | The board UI module. Without it the board shows the public view as JSON. | | `deck.js` | no | The phone UI module. Without it the phone shows the seat's view as JSON. | | `board.css`, `deck.css` | no | Styles; load them from the module with `useStyles("/games//board.css")`. | | `how-to-play.md` | yes, in practice | Rules for players, opened from **Rules** on every phone (section 11). | | `assets/` | no | Images, SVG, sounds, extra CSS or JS the UIs load, at `/games//assets/…` (`ctx.assetUrl(path)`). | The host serves exactly the UI files and `assets/` to browsers, never `rules.ts` or the manifest. Built-in games import the SDK by path (`../../packages/sdk/src/index.ts`); games in other repositories import it by name (`@game-table/sdk`, `@game-table/sdk/cards`), which the host maps to its own SDK. ## 4. The manifest ```json { "id": "coin-flip", "name": "Coin Flip", "version": "2026.10.19.1", "sdk": "^0.1", "description": "Guess heads or tails; the most correct guesses win.", "players": { "min": 1, "max": 8 }, "turnModel": "simultaneous", "seatLayouts": [2, 4, 6, 8], "edges": ["north", "east", "south", "west"], "dynamicPlayers": true, "settings": [{ "key": "rounds", "type": "int", "min": 1, "max": 50, "default": 10, "label": "Rounds" }], "ai": { "supported": true, "options": [] }, "simulation": { "supported": true, "headless": true } } ``` | Field | Meaning | | --- | --- | | `id`, `name`, `description` | Id (= folder name), the name shown everywhere, one sentence for the game menu. | | `version` | Calendar version `YYYY.MM.DD.N` (section 13). | | `sdk` | The SDK version range the game is written for (`"^0.1"` today). | | `players` | `{ "min", "max" }` or a list of allowed counts, 1–8. | | `turnModel` | `"sequential"` or `"simultaneous"`: informational; the rules decide who may act. | | `seatLayouts` | Table layouts (2, 4, 6, 8 seats) the game can be played in. | | `edges` | Edges seats may be on; must include `north` and `south`. | | `localSeat` | Optional edge for a single person at the table when everyone else is remote or AI (they are moved there at the start). For games whose board has a front, like chess. | | `dynamicPlayers` | `true`: people who join during a game get a free seat and `onSeatJoined` is called. Default `false`: newcomers wait for the next game. | | `settings` | Game options set on the board before the start: `{ key, type: "int" \| "number" \| "string" \| "bool" \| "enum", min?, max?, step?, values?, default?, label? }`. The rules get them in `setup`. | | `ai` | `{ supported, options, timeoutSeconds = 20, worker = false }`. `options` uses the settings format (e.g. a play style); each AI player has its own values. Section 7. | | `simulation` | `{ supported, headless }`: AI-only games for testing, without a board. | | `boardIntents` | Intent names players may also send by touching the board (section 9). | | `skipTimers` | Timer ids the board may fire early, e.g. a pause after a hand (section 9). | | `lobbyButton` | Corner of the board's *Lobby* button during the game: `top-left`, `top-right`, `bottom-left`, `bottom-right` (default). | | `viewer` | `{ columns: [{ key, label }] }`: up to four extra columns in the Game Viewer, filled by `describe` (section 12). | | `remoteBoardControl`, `saveGame`, `deck`, `permissions`, `seatConstraints`, `test` | Rarely needed; see the schema. `test: true` hides a game unless the table shows test games. | ## 5. The rules (`rules.ts`) ```ts import { count, defineGame, list, mostAward } from "@game-table/sdk"; interface State { /* plain JSON: numbers, strings, booleans, arrays, objects, null */ } export default defineGame({ setup(ctx, settings) { … return state; }, onIntent(state, seat, intent, ctx) { … return { state: next, events } /* or */ return { reject: "not your turn" }; }, view(state, seat) { … }, // what this seat may see boardView(state) { … }, // what everyone may see isOver(state) { … }, // null while playing; a summary at the end onTimer(state, id, ctx) { … }, // optional onSeatChanged(state, seat, ctx) { … }, // optional: another player plays this seat now onSeatJoined(state, seat, ctx) { … }, // optional: only with "dynamicPlayers": true canContinueWithout(state, seat) { … }, // optional: can the game go on while this player's phone is away? ai(view, seat, ctx, options) { … }, // optional: an AI player's move aiFallback(view, seat, ctx, options) { … }, // optional: a quick move when an AI is out of time describe(step) { … }, // optional: one step in words, after the game }); ``` ### The state - Plain JSON only. The host hashes `JSON.stringify(state)` to check replays, and copies it for views and logs. No classes, `Map`, `Set`, `Date`, functions or `undefined` values that matter. - Treat the state you are given as read-only. Build and return a new one (copy what you change). Never mutate the input: a rejected intent must leave the game exactly as it was. - Keep it reasonably small: it is copied often. Keep only what the rules need. ### The context (`ctx`) | Member | Meaning | | --- | --- | | `ctx.random()` | A number in `[0, 1)` from the game's logged seed. **The only source of randomness.** | | `ctx.randomInt(min, max)` | An integer in `[min, max]`, from the same stream. | | `ctx.setTimer(id, ms)` | Call `onTimer(state, id)` after `ms`. Setting an id again replaces it. Timers stop while the game is paused. | | `ctx.clearTimer(id)` | Cancel a timer. | | `ctx.now()` | Game time in ms since the start, pauses left out, logged with every input (for chess clocks and the like). **The only clock.** | | `ctx.seats` | `SeatInfo[]`: `{ seat, playerId, name, kind: "human" \| "ai", edge }` for the seats at the start (and as they change). | Never use `Math.random()`, `Date.now()`, `new Date()`, `performance.now()`, the network, files or global mutable state in rules. Views (`view`, `boardView`) must not draw random numbers or set timers. Do not set timers in a step you then reject. ### The hooks **`setup(ctx, settings)`** → the first state. `settings` has every manifest setting with its default filled in. Seats are in `ctx.seats`; the seat numbers are the table's (they may have gaps, e.g. seats 0, 2, 4 at a six-seat table). **`onIntent(state, seat, intent, ctx)`** → `{ state, events? }` or `{ reject: reason }`. An intent is `{ name, payload }`, sent by a phone (`ctx.send` in `deck.js`), by the board for a player (`ctx.act`, only names in `boardIntents`) or by an AI player. Check everything: whose turn, the payload's shape and range, the phase. The reject reason is shown to the player, so write it for people ("Wait for your turn", not "E_TURN"). Phones may send an intent for a view they saw a moment ago: include a version in the payload if order matters, and reject stale ones politely. **Events** are `{ name, data? }` describing what just happened, for the UIs to animate (a card played, a die rolled, a token moved). They go to the board and to every phone with the new views, and into the log. They are not state: everything a UI needs to draw the current position must be in the views. **`view(state, seat)`** → what one seat may see: their own cards, their legal moves, whose turn it is, the public position. Put `myTurn: true` in it while the game waits for this player: the phone then brings the game forward (section 10). Never include other seats' private data. **`boardView(state)`** → what everyone may see. Shown on the table's board and on remote boards. **`isOver(state)`** → `null` while the game runs, else the summary (section 8). Called after every step. **`onTimer(state, id, ctx)`** → like `onIntent`, for a timer the game set. Turn timers, a pause on a hand's result, an automatic move when a player runs out of time. **`onSeatChanged(state, seat, ctx)`** → another player plays this seat from now on (section 6). Update what the state keeps about the seat's player (the name; whether it is a person, if the rules care). The seat's cards, score and position stay. Logged and replayed like an intent. **`onSeatJoined(state, seat, ctx)`** → only with `"dynamicPlayers": true`: someone took a free seat during the game. Add them to the state. **`canContinueWithout(state, seat)`** → `true` (the default) or `{ wait: "text for the board" }`. Asked when a player's phone goes away. With `{ wait }` the game pauses until the phone is back or the board exchanges the player; the board shows the text (e.g. "Partners can't go on without a full team."). A question about the state: it must not change anything. **`onSeatLeft(state, seat, ctx)`** → kept for old logs only. A running game never loses a player (section 6), so the platform does not call it during games. Do not build game logic on it. **`ai`, `aiFallback`** → section 7. **`describe`** → section 12. ### The SDK `@game-table/sdk` (`packages/sdk/src/index.ts`): `defineGame`, the types above, `createRng(seed)` (the same generator for tests and AI search), `count(n, "die", "dice")` → "one die", "three dice", `list(["Ann", "Bob", "Cleo"])` → "Ann, Bob and Cleo", `isReject(result)`, `mostAward`, `leastAward` (section 8). `@game-table/sdk/cards` (`packages/sdk/src/cards.ts`): a 52-card deck, a seeded `shuffle(items, ctx.random)`, poker hand evaluation (`bestHand`, `compareScores`, `HAND_NAMES`). ## 6. Players and seats The platform owns everything about players. A game must not reimplement any of this; it only answers the hooks. | What happens | What the platform does | What the game gets | | --- | --- | --- | | Players join, take seats, pick colours, change names, move seats | In the lobby, before the game. Seats are fixed when the game starts. | `ctx.seats` in `setup`. | | Someone joins during a game | Seated only if the manifest says `"dynamicPlayers": true` and a seat is free; else they wait for the next game. | `onSeatJoined`. | | A player's phone goes away (locked, out of range, closed) | Their seat **waits, with no time limit**; the game goes on and simply waits when it is their move. After 15 s the board shows "… is away" at their seat with **Exchange …**. The phone rejoins by itself when it is back. | Nothing (the state does not change). If the game answers `{ wait }` in `canContinueWithout`, the game pauses meanwhile. | | A player taps **Leave the game** on their phone | Their phone is done with this game. The seat is handled like an away player's at once: "… left the game" with **Exchange …**. | Nothing until the seat is exchanged. | | The board exchanges a player (Settings › Players › tap the name, **Exchange …** on the away card, the in-game Lobby) | For anyone, away or connected: a person at the table (a QR code for this seat), a remote player (an invite; only with a remote place free), a watcher or an AI player takes the seat, with its colour. Whoever had it goes; their phone is told, and a remote player's browser keeps watching. One exchange is open at a time; a new one replaces it, and it ends with the game. | `onSeatChanged(state, { seat, playerId, name, kind })`. | | Every seat is taken | Nobody else joins: phones with the join code, + AI player and player invites are refused with the reason. During a game the way in is an exchange; someone remote can still come as a watcher. | — | | Someone remote watches | They see the board only, take no seat and have no phone page; remote players and watchers together use at most the table's remote places (4 by default). | Nothing. | | An AI player's options change during the game | From its next move. | Nothing (options reach `ai()` as its `options`). | | Removing a player during a game | **Not possible.** The game always has the same players' seats. | — | | A timeout that hands a seat to an AI | **Does not exist.** Replacing a player is always a choice made at the table. | — | | The table loses its service connection, or an AI runs over its time | The game pauses (timers stop, nobody moves) and resumes by itself or from the board. | Nothing. | What this means for a game: - Keep player names in the state only if the game shows them from the state (end-of-game texts, "it is Ann's turn"). Then implement `onSeatChanged` and update them, or the old name stays until the game ends. - If the rules treat people and AI players differently (an acknowledgement only people must give, a choice the rules make for an AI seat), keep that flag per seat and update it in `onSeatChanged`. Release anything still owed by a seat that becomes an AI, so the game cannot wait forever for a phone that is gone. - Whether a person is at the table or remote is not part of the rules' seat information; the UIs know it from `info.players` (`location`). Rules should not need it. - Never pause, end or change a game because a player is away: the platform shows it and the people at the table decide. If the game truly cannot go on without someone (a team game whose partners must both act), answer `canContinueWithout` with `{ wait }` and the platform pauses for you. ## 7. AI players `ai(view, seat, ctx, options)` → an intent, `null` (nothing to do now), or a promise of either. - **It sees only the seat's view**, never the state: the same information a person in that seat has. If the AI needs more to plan (what it has seen so far), put it in that seat's view (e.g. an `observer` field only AI seats get), never other seats' secrets. - **It is asked after every change**, for every AI player: answer `null` quickly when it is not this seat's turn. A newer change cancels an older question (`ctx.signal` is aborted and the answer is dropped). - **Randomness:** `ctx.random()` / `ctx.randomInt()` (the AI's own stream, not the game's). The move itself is logged, so replay never runs the AI again. - **Time:** answer synchronously for quick choices. For a search or a language-model call return a promise; the table keeps running. `ctx.deadline` (ms since the epoch) is when the table expects the move (`ai.timeoutSeconds`, default 20). Past it, the game pauses and the board offers **Play now**, which plays `aiFallback()`: a quick synchronous move (without one, the AI is asked again). `"ai": { "worker": true }` runs `ai()` in a worker thread per AI player, with the view and options as plain data, so a long synchronous search never blocks the table. - **Options** come from `ai.options` in the manifest, per AI player, e.g. `{ "key": "style", "type": "enum", "values": ["cautious", "bold"], "default": "cautious", "label": "Play style" }`. - **Pace:** the platform lets an AI move only once the table's board has shown everything so far (section 9), and phones get an AI's move once the board has shown it. Do not add artificial delays in `ai()`; let the board stage moves. - **Simulations** (`"simulation": { "supported": true }`): AI-only games without a board, for testing and AI tuning; `scripts/soak.ts` runs many of them. They must finish: make sure AI players always find a move or `null` that lets a timer move the game on. ## 8. The end of a game `isOver(state)` returns `null` while the game runs, and at the end: ```ts { text: "Ann and Cleo win with all eight marbles home after 14 rounds.", // one or two sentences highlights: { winners: ["Ann", "Cleo"], rounds: 14 }, // machine-readable facts table: { columns: ["Player", "Captures", "Moves"], rows: [["Ann", 3, 41], ["Ben", 1, 39]] }, // optional awards: [{ id: "killer", icon: "💀", title: "The Killer", blurb: "Captured the most marbles.", seats: [0], value: "5 captures" }], // optional } ``` - **`text`** is the result screen's headline and the log's result. Use `count` and `list` for natural texts. - **`table`** (optional): per-player statistics, shown on the result screen and in the statistics window. Put the player's name in the first column, so the platform can mark a seat that changed hands. - **`awards`** (optional): the statistics window's trophies. `mostAward(base, scores, value?)` and `leastAward(base, scores, value?)` build one from `[{ seat, score }]` and return `null` when nobody stands out (one player, everyone tied, or the best score is 0). The platform drops awards that nobody or every player holds. - **The platform adds** each seat's final player (`players`), a moves-and-thinking-time table (`play`) read from the log, and two pace awards for people. A seat another player took over during the game shows the last player's name with *; a note explains it. The board keeps the game's final screen until someone taps **Show result**, so the end can be seen. If the board UI replays moves (section 9), **Show result** waits for it. ## 9. The board UI (`board.js`) A plain ES module, loaded by the board page (no build step, no framework required). It draws the public view and animates events. ```js import { esc, html, playerColor, seatPlates, remainingMs, playSound, useStyles } from "/shared/board-kit.js"; export function mount(root, ctx) { useStyles("/games/my-game/board.css"); // ctx: { gameId, assetUrl(path), act(seat, name, payload), skipTimer(id) } return { update(view, events, info) { /* draw boardView; animate events */ }, destroy() { /* stop timers, remove listeners */ }, whenIdle() { return Promise.resolve(); }, // optional: resolves once everything sent so far has been shown }; } export function settings({ info, setPrefs }) { return []; } // optional: items for the board's settings window ``` - **The surface** is 1920 × 1080 logical pixels, scaled to the screen; `root` covers it. Seats sit around the edges (`info.seatPositions`), so keep the middle for the game and the edges for seat plates (`seatPlates()` draws them with names, colours and whose turn it is). - **Facing:** a game on the table's board faces `north` or `south` (the turn button switches); remote boards turn to their player. Draw for `info.frontEdge` / `info.rotation` (degrees: south 0, west 90, north 180, east −90). Text should face the reader it is for. - **Platform buttons:** the gear and the turn button sit at the bottom-left (bottom-right when `lobbyButton` is bottom-left), the *Lobby* button in the manifest's `lobbyButton` corner. Keep those corners clear. - **`update(view, events, info)`** is called with every change (and when `info` changes, with `events` empty). Make it cheap and idempotent: redraw from `view`, animate only `events` you have not shown yet. - **`whenIdle()`** (strongly recommended if you animate): resolve when every event sent so far has been shown. The platform waits for it before an AI player moves, before phones see an AI's move, and before **Show result**. Without it the platform assumes the board shows everything at once. Never let it wait forever (resolve when nothing is queued). - **Moves on the board:** list intent names in `boardIntents`; `ctx.act(seat, name, payload)` sends one for a seat in `info.controlSeats` (people at the table on the table's board; a remote board only for its own player) and resolves with `{ ok, reason }`. The rules decide as for any intent. - **Skipping a wait:** list timer ids in `skipTimers`; `ctx.skipTimer(id)` fires it now when `info.boardControl` is true. Use it for a pause on a result ("Next hand"). - **Settings:** `settings({ info, setPrefs })` returns items `{ type: "toggle" | "choice" | "swatches" | "button", label, value, options, onChange }`. `setPrefs(patch)` stores display choices for this game (`info.prefs`), never rules. - **Sound:** `playSound(info, name, make)` from the board kit plays only when the table's sound is on (`info.sound`). - **Performance:** the board may be a modest PC driving a large screen. Avoid endless CSS animations on large layers and rebuilding big SVG or DOM trees per frame; split static and moving layers, update only what changed, and move pieces with transforms or the Web Animations API (one animation along the whole path, not a step per frame). ### `info` on the board | Field | Meaning | | --- | --- | | `players` | Everyone at the table: `{ id, name, kind: "human" \| "ai", location: "local" \| "remote", seat, connected, ready, color, aiOptions? }`. | | `seatColors` | Always empty since a running game keeps all its players; kept so older UI code still works. Use `playerColor(info, seat)` for a seat's colour. | | `seatEdges`, `seatPositions` | Each seat's edge; each seat's position on the surface. | | `frontEdge`, `rotation` | Which way the game faces on this board. | | `controlSeats`, `boardControl` | Seats this board may act for; whether it may use table controls. | | `settings`, `prefs` | The game's settings for this game; display choices. | | `timers`, `now`, `receivedAt` | Timer deadlines in host time, the host's clock and when the message arrived: `remainingMs(info, id)` gives a countdown. | | `over` | The game has ended. | | `aiThinking` | AI players thinking for more than a moment: `{ playerId, seat, since, deadline }`. | | `sound` | Sound effects on. | ## 10. The phone UI (`deck.js`) ```js export function mount(root, ctx) { // ctx: { gameId, seat, send(name, payload) → Promise<{ ok, reason? }>, toast(text), vibrate(ms), assetUrl(path) } return { update(view, events, info) { … }, destroy() { … } }; } ``` - **The phone during a game** has a slim tab bar at the top (the player's own tab and the game's tab, about 60 px); the game's module gets the rest of the screen. Design for 360 px wide and for short screens (an iPhone with its toolbars shows about 400 × 680). Large touch targets. - **`ctx.send(name, payload)`** submits an intent and resolves with `{ ok, reason }`; a rejection is shown to the player as a toast by the platform. - **`myTurn: true`** in the seat's view brings the game's tab forward when it becomes true. - **Staging:** phones get an AI player's move once the board has shown it; then `info.shownOnBoard` is true. Apply such updates at once rather than animating the move again. - **Facing:** `info.viewEdge` (the edge this player looks from) and `info.boardEdge` (the edge the board's game faces) let a phone turn a map the way the player sees the table. ### `info` on the phone | Field | Meaning | | --- | --- | | `seat`, `me` | This player's seat and their entry from `players`. | | `players`, `seatColors`, `seatEdges` | As on the board. | | `viewEdge`, `boardEdge` | How this player sees the board. | | `settings`, `prefs`, `timers`, `now`, `receivedAt`, `over`, `aiThinking` | As on the board. | | `shownOnBoard` | This update's moves have been shown on the board already. | ## 11. Rules for players (`how-to-play.md`) Every phone has a **Rules** button while the game is chosen or running; it shows this file in a sheet, with the table's settings (by their manifest labels) on top. - Start with a one-sentence goal, then `## In short` with two to five bullets (the tests require it), then details. - Name every setting by its manifest `label`. - A small, safe Markdown subset (`/shared/markdown.js`): `#`, `##`, `###`, paragraphs, `-` and `1.` lists (one level), `**bold**`, `*italic*`, `` `code` ``, `> tip`, `---`, simple tables, `https` links, and images from `assets/` (`![alt](assets/board.svg)`: SVG, PNG, JPEG, WebP, GIF). Everything else shows as plain text. - Write for a 360 px phone: short sentences, short tables, illustrations 360–720 px wide. ## 12. Logs, replay and the Game Viewer - The host logs the seed and every input (intents, accepted or refused, timer firings, seats joining or changing hands) with its game time. `replay()` rebuilds the game from that, and the host checks every finished game this way. A game that does not replay exactly has a bug: usually randomness or time outside `ctx`, or a mutated input state. - Each log is a zip with `game-001.jsonl` (records), `game-001.moves.jsonl` (readable rows) and a README (`apps/table-host/src/log-format.md`). `node scripts/game-log.ts ` reads one from the command line (`info`, `moves 40-45`, `move 43` with the state before and after); `--games DIR` adds game folders from other repositories. - **`describe(step)`** (optional) says what a step meant, for the Game Viewer on the board, the move list and readers of logs. `step` has `kind` (`intent`, `timer`, `seatJoined`, `seatLeft`, `seatChanged`), `seat`, `intent` and `by` (`human`, `ai`, `board`), `timer`, `accepted`, `reason`, `events`, `names` (seat → name) and the state `before` and `after`. Return `{ text, columns?, details?, steps? }` or `null` for the generic line: `text` without the player's name ("plays a Seven: 4 steps for marble 3"); `columns` for the manifest's `viewer.columns`; `steps` for things that happened within the step for other seats (`{ seat?, text, auto? }`). Pure, and only used after the game. ## 13. Versions A game's `version` is `YYYY.MM.DD.N` (America/Chicago date; `N` counts that day's versions from 1). It changes when, and only when, something in the game's folder changes (rules, UI, styles, assets, rules for players, the manifest), in the same pull request. Platform changes never change it. `node scripts/check-game-versions.ts --base origin/main --fix` sets it for you; the pull request checks enforce it. ## 14. Testing Tests use Vitest and run against the real host code. - **Rules:** `tests/harness.ts` in this repository loads a game folder and runs it on the real runtime with virtual time: `const { rt, clock } = start(game, players, settings, seed)`; drive it with `rt.submit(seat, intent, "human")`, `clock.advance()` (fires due timers) and `rt.seatChanged(seatInfo)`, read `rt.view(seat)`, `rt.boardView()`, `rt.state` and `rt.over`, and check `expect(expectReplay(game, rt)).toBe(true)` once the game has ended. - **Host:** `tests/helpers.ts` starts a real Table Host (`testHost()`) with fake phones (`joinDeck(name)`, `deck.request("game", "intent", …)`, `deck.waitView(…)`) and the board API (`t.ok("POST", "/game/start")`). - **By hand, without a phone:** *Advanced › Developer › Play from the board* on the board (or `BOARD_DECK=1`) puts one player's deck beside the board, as on a remote player's page, so you can play your game at one screen with AI players (D-175). Only one person plays at the table meanwhile, since the deck is private; remote players can still join. - **Browser:** `scripts/e2e/` drives a board and phones in Chromium with Playwright; `common.py` has the helpers. - What every game should test: a whole game to the end with AI players or scripted moves, that it replays exactly, that a seat's view never contains another seat's secrets, that illegal intents are rejected with a reason, an exchange mid-game (`onSeatChanged`) if the game keeps names, and `how-to-play.md` (the `rules.test.ts` checks run on every game folder in this repository). ## 15. A game repository of your own Games can live in their own GitHub repository (private or public). The service fetches its `games/` folder as one bundle, and an administrator ticks the repository for the tables that should get its games. [`klaushofrichter/extra-games`](https://github.com/klaushofrichter/extra-games) is a working example of everything below: copy its `scripts/games-service.sh`, `games-service.ref`, `tsconfig.json`, `vitest.config.ts`, tests and CI workflow. ``` my-games/ games//… one folder per game, exactly as in section 3 games-service.ref the games-service branch, tag or commit the games are tested against (e.g. "main") scripts/games-service.sh clones games-service at that ref into .games-service/ (git-ignored) and installs it tsconfig.json, vitest.config.ts map "@game-table/sdk" to .games-service/packages/sdk/src/index.ts tests/ import the harness and helpers from .games-service/tests/ ``` - Rules import `@game-table/sdk` by name. The host maps it to its own SDK, so the same code runs on every table. - A game id must not be a built-in game's or another repository's. - Test against games-service `main` regularly (a weekly scheduled check is a good idea): platform changes show up there first. - An optional hook a game uses must be one the tables have. New hooks are optional, so an older host simply does not call them; do not import SDK names that older hosts do not export. - Add the repository to the service: an administrator adds `owner/name` and a branch in the console (or in `deploy/game-repos.json` by pull request), and gives the service read access to a private repository. ## 16. Checklist before a pull request - [ ] `manifest.json` validates; `version` bumped for any change in the folder. - [ ] Rules use only `ctx.random()`, `ctx.randomInt()` and `ctx.now()` for chance and time; no input state is mutated. - [ ] Every intent is checked (turn, phase, payload) and rejections have a sentence for people. - [ ] `view(state, seat)` holds no other seat's secrets; `boardView` holds nothing private. - [ ] `isOver` returns a summary with a readable `text`; `table` has the player's name first. - [ ] Names kept in the state are updated in `onSeatChanged`; nothing about leaving, replacing or timeouts is implemented by the game. - [ ] AI players answer `null` quickly when it is not their turn and only read their view. - [ ] The board UI animates events, keeps the corners clear, and implements `whenIdle()` if it replays moves. - [ ] The phone UI fits 360 × 600, sends intents with `ctx.send`, and uses `myTurn`. - [ ] `how-to-play.md` starts with the goal and `## In short`, and names settings by their labels. - [ ] Tests: a whole game, exact replay, private views, rejections, an exchange mid-game. --- # Runbook: Game Table on one machine with Docker Runs the Game Table platform locally: the **Table Host** (lobby, games, AI players, the board and the phone pages) with the built-in games, in Docker, on a computer in the same WiFi as the players' phones. It needs **no cloud service, no DNS entry and no HTTPS**, and nothing from `games.skylar.technology`. Written to be followed by a coding agent (for example Claude) as much as by a person. Typical instructions: *"Check out https://games.skylar.technology and read the llms.txt. I want to do a local installation."*, or *"Read https://github.com/klaushofrichter/games-service and set up a running version of the core service with the built-in games."* Both lead here (also served at https://games.skylar.technology/docs/local-docker.md). That is Part A below; Part B adds games from other repositories, public or private; Part C, rarely needed, a local cloud service. ## For an agent following this - Do the steps in order and run each **Check**; when a check fails, stop and report it with the output instead of improvising around it. *Troubleshooting* at the end covers the usual causes. - You can verify everything except the phones yourself: health, games, simulated AI games (A5) and the board page. Ask the person to scan the QR code with a phone for the last check (A6). - Do not change files in the repository to make the setup work; everything is configured in `deploy/local/.env`. - Asked to add games from another repository, public or private: that is Part B (B2 for a private one: a clone and a folder), not Part C. The cloud service is never needed for games at the table. - Report at the end: the board URL, the phone URL (`PUBLIC_URL`), and that `deploy/local/.env` holds the table key (do not paste the key into shared logs or chats). ## What you get, and what you don't | Works locally (Part A) | Needs a cloud service (Part C, or not at all) | | --- | --- | | Board and phones over plain `http://` on the LAN, joining by QR code | Remote players outside the room | | The built-in games (Coin Flip, Hold'em, Pachisi, Roulette), AI players, statistics | Installing extra-game repositories through a console (Part B adds games without one) | | Game logs, saves, the Game Viewer, simulations, the board control API | Uploading logs, table metrics, the admin console | Without a cloud service the table runs in *development mode*: nothing to pair, no pairing code, games start at once. **Do I need the cloud service (its console)? No**, not to play at the table: the built-in games and games from any other repository, public or private, run without it (Part B: clone the repository, point the table at its game folders). The cloud service only adds remote players outside the room, installing game repositories through its console, uploading logs and table metrics (Part C). ## Prerequisites - **Docker** with the Compose plugin (`docker compose version` prints v2 or later): Docker Engine on Linux, Docker Desktop on macOS or Windows (on Windows, run the commands in WSL or Git Bash). - **git**, and about 1 GB of disk for the image. - The machine and the phones on **the same network**, not a guest WiFi that isolates devices. Port **8080** free and reachable from the LAN (a firewall may ask, or need a rule; see *Troubleshooting*). - A browser on the machine for the board; a touchscreen is optional (a mouse works). ## Part A: the table with the built-in games ### A1. Get the code ```sh git clone https://github.com/klaushofrichter/games-service cd games-service ``` All later commands run from this folder. ### A2. Write the settings ```sh deploy/local/setup.sh ``` It writes `deploy/local/.env` with `PUBLIC_URL` (this machine's LAN address, which goes into the QR code) and a random `TABLE_KEY` (the key of the board control API). It keeps an existing `.env`; delete the file to start over. **Check:** the script prints `PUBLIC_URL=http://
:8080`. The address must be the one phones reach, usually `192.168.x.x` or `10.x.x.x`. If it picked the wrong one (a VPN, Docker's own `172.17.x.x`), run `rm deploy/local/.env && LAN_IP= deploy/local/setup.sh`. To find the address: `ipconfig getifaddr en0` (macOS), `ip -4 route get 1.1.1.1` (Linux, the `src` value) or `ipconfig` (Windows, the WiFi adapter's IPv4 address). ### A3. Build and start ```sh docker compose -f deploy/local/docker-compose.yml up -d --build ``` This builds the Table Host image from the checkout (a few minutes the first time) and starts it in the background; Docker restarts it after a reboot. Logs and saves live in the Docker volume `local_table-data`. **Check:** ```sh curl -s http://localhost:8080/health # {"status":"ok","version":"dev","protocol":1} docker compose -f deploy/local/docker-compose.yml logs table-host | head ``` The log names the board URL, the join URL with your LAN address, the games, and `CLOUD_URL not set: running without the cloud service (development mode)`. ### A4. Check from the LAN address ```sh set -a; . deploy/local/.env; set +a curl -s "$PUBLIC_URL/health" # the same answer as above curl -s -H "X-Table-Key: $TABLE_KEY" "$PUBLIC_URL/api/v1/join" # {"token":"…","url":"http://:8080/j/…"} curl -s -H "X-Table-Key: $TABLE_KEY" "$PUBLIC_URL/api/v1/games" | grep -o '"id":"[a-z-]*"' ``` **Check:** the games include `coin-flip`, `holdem`, `pachisi` and `roulette`, and the join URL starts with `PUBLIC_URL`. If the LAN address does not answer while `localhost` does, it is the firewall (*Troubleshooting*). ### A5. Play games with AI players (no phone needed) ```sh curl -s -H "X-Table-Key: $TABLE_KEY" -X POST "$PUBLIC_URL/api/v1/simulation" \ -d '{"gameId":"coin-flip","players":4,"games":20,"seed":42}' | grep -o '"endReasons":{[^}]*},"verified":[a-z]*' ``` **Check:** `"endReasons":{"finished":20},"verified":true`: twenty AI-only games ran to the end, and each one replayed from its log to the same result. Every built-in game id works the same way. ### A6. Open the board and join with a phone 1. On the machine, open **`http://localhost:8080/board/`** in a browser, full screen (F11, or ⌃⌘F on macOS). The real setup is a 1920 × 1080 or larger touchscreen lying flat; a normal monitor works for trying it. 2. Scan the QR code on the board with a phone (or type the address under it) and enter a name. The phone shows the lobby, and the board shows the player at a seat. 3. On the board, choose a game, add AI players with *+ AI player* if you like, and start. Each phone shows its player's private part of the game (cards, bets, controls). No phone at hand (a developer trying a game alone): on the board, open *Advanced* (hammer and wrench) › *Developer* › *Play from the board*. The board then shows one player's deck beside it, like a remote player's page; enter a name there instead of scanning the QR code. It lasts until the Table Host restarts, or set `BOARD_DECK=1` in `.env`. **Check:** a phone (or the deck beside the board) joined, and a game started and ended. *How to play* on the board and on the phones explains each game. ### Everyday | Task | Command (from the repository folder) | | --- | --- | | Logs | `docker compose -f deploy/local/docker-compose.yml logs -f table-host` | | Stop / start | `docker compose -f deploy/local/docker-compose.yml stop` / `… start` | | Update to the newest code | `git pull && docker compose -f deploy/local/docker-compose.yml up -d --build` | | Another network (new LAN address) | `rm deploy/local/.env && deploy/local/setup.sh`, then `up -d` as in A3 | | Remove everything, data included | `docker compose -f deploy/local/docker-compose.yml down -v` | | The published image instead of building | `TABLE_HOST_IMAGE=ghcr.io/klaushofrichter/games-table-host:latest docker compose -f deploy/local/docker-compose.yml up -d --no-build` (linux/amd64 only; on Apple Silicon, build) | | Board control API | everything the board can do, over HTTP with `X-Table-Key`: README, *Board control API* | More Table Host settings (time limits, the AI's pause, remote seats) are environment variables, listed in the README under *Table Host configuration*. ## Part B (optional): more games, from any repository Games from other repositories run without the cloud service: a game is a folder (`games//` in its repository, with a `manifest.json`), and the table runs every game folder it is given. You clone the repository, copy its game folders next to the built-in ones, and point the table at that folder. A **private repository** works the same way (B2): only the clone needs access; the table needs no token and no console. ### B1. Games from a public repository The example is the public [`klaushofrichter/extra-games`](https://github.com/klaushofrichter/extra-games) (Chess, Liar's Dice, Backgammon). Put all game folders, the built-in ones included, into one folder and point the table at it: ```sh git clone https://github.com/klaushofrichter/extra-games ../extra-games mkdir -p ../table-games cp -r games/* ../extra-games/games/* ../table-games/ echo "GAMES=\"$(cd ../table-games && pwd)\"" >> deploy/local/.env docker compose -f deploy/local/docker-compose.yml up -d ``` **Check:** `curl -s -H "X-Table-Key: $TABLE_KEY" "$PUBLIC_URL/api/v1/games" | grep -o '"id":"[a-z-]*"'` also lists `backgammon`, `chess` and `liars-dice` (and the games of every repository you copied). - `GAMES` must be an absolute path; the folder is mounted read-only at `/app/games` (there, the games find the SDK). - Game folder names must differ between repositories: a folder copied later replaces one with the same name. - To update: `git pull` in each clone, copy the game folders again, then `docker compose -f deploy/local/docker-compose.yml restart table-host`. - A game repository is tested against a version of this one (often named in its `games-service.ref`); if a game does not load, the table's log says why (`docker compose -f deploy/local/docker-compose.yml logs table-host`), and updating this checkout (`git pull`, then `up -d --build`) usually helps. - Writing a game of your own: the developer guide, https://games.skylar.technology/docs/game-development.md (in the repository: `docs/developers/game-development.md`). ### B2. Games from a private repository A private game repository is installed like the public one above: cloned with your own access, its game folders copied next to the others. Nothing on the table side is private-specific: no token in `deploy/local/.env`, no cloud service, no console. Only the clone needs access. 1. **Access:** your GitHub account must be able to read the repository (the owner adds you, or you are in the organization). Check: `git ls-remote https://github.com//` (or the SSH form below) lists its branches instead of saying *Repository not found*. For a coding agent: use the person's existing git access on this machine; ask them if the check fails, and never put a token into the repository or into `deploy/local/.env`. 2. **Clone** it next to this checkout, with whichever access the machine has: ```sh git clone git@github.com:/.git ../ # SSH key registered with GitHub git clone https://github.com//.git ../ # HTTPS: git asks, or uses the credential helper gh repo clone / ../ # GitHub CLI, after `gh auth login` ``` Use the branch the repository releases from, if it has one (for example `git -C ../ checkout production`); tables run what that branch holds. 3. **Add its games** to the folder the table uses, then let the table load them. The first three lines are what B1 sets up; they change nothing when it is done already: ```sh mkdir -p ../table-games [ -d ../table-games/coin-flip ] || cp -r games/* ../table-games/ # the built-in games, once grep -q '^GAMES=' deploy/local/.env || echo "GAMES=\"$(cd ../table-games && pwd)\"" >> deploy/local/.env cp -r ..//games/* ../table-games/ docker compose -f deploy/local/docker-compose.yml up -d # picks up GAMES the first time docker compose -f deploy/local/docker-compose.yml restart table-host # loads the games in the folder again ``` 4. **Check:** the games list (as in B1) now has the repository's game ids (its `games/` folder names); the table's log names them at start (`docker compose -f deploy/local/docker-compose.yml logs table-host | grep games:`). 5. **Update** later: `git -C ../ pull`, copy again (step 3), restart. The games stay on this machine: the table serves them to the phones on the LAN and to nobody else. Several private repositories: repeat steps 2-5 for each; their game folder names must differ. ## Part C (optional): a local cloud service Adds the cloud service on the same machine, to try what it does: the admin console, extra-game repositories installed through it, log uploads and remote play. Still plain `http://`, no DNS, no Google sign-in. ```sh docker compose -f deploy/local/docker-compose.yml down # keeps the data rm deploy/local/.env && deploy/local/setup.sh --cloud # a new table key, plus ADMIN_TOKEN and the cloud profile docker compose -f deploy/local/docker-compose.yml up -d --build ``` 1. The board now shows a 6-character **pairing code**. Open **`http://localhost:8081/admin`**, sign in with the `ADMIN_TOKEN` from `deploy/local/.env`, and approve the code. The notice on the board disappears. 2. The console's game repositories list `klaushofrichter/extra-games`; tick it for the table, and the table installs those games within a minute (the cloud service needs to reach `api.github.com`). More repositories: list them in `GAME_REPOS` in `deploy/local/.env` (`owner/name@branch`, comma-separated). A private one also needs `GAME_REPO_TOKEN` there: a GitHub token that can read it (fine-grained, *Contents: read*). This is only the console route; Part B2 needs neither. 3. *Invite* on the board creates a link for a remote player. **Limit of plain http:** browsers allow remote play (it uses a service worker) only on `https://` or on `localhost`. So an invite link (`http://localhost:8081/…`) works in a browser on this machine, which is enough to try it, but not on another device. Remote play across networks needs the cloud service on an HTTPS address, which is out of scope here. Players at the table and their phones are not affected: they never need HTTPS. **Check:** `curl -s http://localhost:8081/health` answers `{"status":"ok",…}`, and the console shows the table online. Back to Part A: `docker compose -f deploy/local/docker-compose.yml down && rm deploy/local/.env && deploy/local/setup.sh`, then `up -d` as in A3 (the cloud profile goes with the old `.env`). ## Troubleshooting | Symptom | Cause and fix | | --- | --- | | `required variable TABLE_KEY is missing` | No `deploy/local/.env`: run `deploy/local/setup.sh` (A2) | | Phone: the page does not load | The phone is on another network, a guest WiFi with client isolation or a VPN, or the machine's firewall blocks port 8080. Test `curl "$PUBLIC_URL/health"` from another computer. Firewall: macOS *System Settings › Network › Firewall* (allow Docker); Linux `sudo ufw allow 8080/tcp`; Windows: allow Docker Desktop for private networks | | The phone opens a wrong address | `PUBLIC_URL` is wrong (A2 check): rewrite `.env` with `LAN_IP`, then `up -d` | | Board: `the board is only served on the table PC` | Not this compose file, which sets `ALLOW_REMOTE_BOARD=1`. Use it, and open `http://localhost:8080/board/` | | `port is already allocated` | Something else uses 8080: set `PORT=8090` and `PUBLIC_URL=http://
:8090` in `.env` | | The build cannot pull `node:26-bookworm-slim` | No access to Docker Hub (a proxy, offline): use the published image (*Everyday*) or a registry mirror | | Games do not start; the board says the table is offline | `CLOUD_URL` is set but no cloud service runs: remove `CLOUD_URL` and `COMPOSE_PROFILES` from `.env` (Part A) | | A remote invite link fails on another device | Expected over plain http (Part C, *Limit*) | ## Security notes This setup is for a home or another trusted LAN. The board page, and with it control of the table, is served to every device that reaches port 8080 (`ALLOW_REMOTE_BOARD=1`), because the board's browser reaches the container from outside its loopback. Do not expose port 8080 to the internet. A real table PC (`deploy/table-pc/`) differs: host networking, the board served only to the PC itself, and the cloud service over HTTPS.