Concepts
Tablecraft has five moving parts. Once these click, the rest of the docs are lookup tables.
intents { name, args }
┌──────────────────────────────────────────────┐
▼ │
┌────────┐ setup / moves / view ┌─────────────┴──┐ view + fx (per player) ┌───────────────┐
│ game.js│ ───────────────────────▶ │ Host │ ─────────────────────────▶ │ <Tabletop> │
└────────┘ │ Match · runtime│ │ 3D table + HUD│
│ bots · timers │ └───────────────┘
└────────────────┘Game module
One JavaScript file that owns the rules: how the table is set up, which moves exist and what each player can see. It never touches the DOM, the network or timers. See Game module.
Host
Something has to run the module and be the single source of truth. Tablecraft ships two hosts with the same behaviour:
| Host | Where | Use it for |
|---|---|---|
createLocalMatch() |
the browser | demos, solo play against bots, fast iteration while writing rules |
createRoomServer() |
Node + socket.io | real multiplayer, spectators, anything where players must not cheat |
Both wrap a Match — the lobby → playing → finished state machine with seats, bots, sand timers and pacing — and both
return the same MatchClient interface to the UI, so switching from local to online is a one-line change.
Views, not state
Clients never receive the game state. After every change the host calls view(state, { playerId }) for each
player and sends the result. The view contains a 3D scene (zones and objects) and a hud. Hidden information is
simply absent: an opponent's card has faceUp: false and no face; a deck has a count, never an order.
Because objects keep stable ids, the client can diff two views and animate the difference: a card that moved from
the market to your hand flies there; a card whose faceUp changed flips; a token stack whose count grew gets coins
dropped on it. You never write animation code for state changes.
Intents
Players click glowing objects (click: { name, args }) or submit forms in the HUD. Both send an intent — a move
name and arguments — to the host. The host runs moves[name](state, ctx); the move validates and either returns the
new state or { error }. Errors go back to the sender only; nothing changes.
Effects (fx)
Some things are not state: the roll of a die, a "Round 3" banner, a storm that shakes the camera. Moves emit them with
ctx.fx(...) (and ctx.roll() emits the dice throw), the host forwards them alongside the next view, and the client
plays them once. to: [playerIds] makes an effect private. See Effects.
Determinism
ctx.random is a seeded generator whose state is stored with the match. Given the same seed and the same intents, a
game replays identically — on the server, in the browser, in the referee. Never use Math.random() or Date.now() in
a module; use ctx.random and ctx.now.
Bots, pacing and the referee
If a module exports actions(state, { playerId }) — every legal move for that player right now — bots can play it
(optionally guided by bot()), and the referee can play hundreds of games looking for crashes, rejected moves, stuck
states and broken invariants. Hosts pace bot moves so humans can follow: longer after dice, banners and reveals. See
Bots & referee and Following the game.