| name | boardgame-io |
| description | Build turn-based games with the boardgame.io framework (v0.50.x API). Use whenever the user wants to create, extend, or debug a board/card/dice/strategy game with boardgame.io — e.g. "make tic-tac-toe / chess / a card game", "add multiplayer to my boardgame.io game", "define phases/moves/turns", writing bots, lobby/matchmaking, or any mention of boardgame.io, bgio, 回合制游戏, 桌游, 棋牌游戏, 桌面游戏开发. Covers game state design, moves, phases, stages, turn order, events, random, secret state, React board UI, client/server/lobby setup, and testing. |
boardgame.io Game Development
Create turn-based games (board games, card games, dice games) with the
boardgame.io framework, targeting the v0.50.x API.
This skill guides the full workflow: designing game state, defining rules,
building the UI, wiring multiplayer, and testing.
When to use
Use for any request to create, extend, or debug a game built on boardgame.io —
including tic-tac-toe / chess / card / dice / strategy games, 棋牌 / 桌游 /
回合制游戏 — in JavaScript or TypeScript, with or without a UI.
Golden rules (read first — these prevent 90% of bugs)
The framework runs your move/hook functions identically on client (optimistic)
and server, serializes all state over the network, and replays state for undo
and bots. Everything below follows from that:
G must be JSON-serializable. Plain objects, arrays, numbers, strings,
booleans, null. No class instances, no functions, no undefined inside
arrays. Use IDs/indices instead of references.
- Never touch
ctx. It is framework-managed. To end a turn, change
phases, etc., call events.* (see below). In moves, read ctx
(ctx.currentPlayer, ctx.numPlayers, ctx.playOrder, ctx.phase,
ctx.activePlayers), never write it.
- Never use
Math.random() or Date.now(). Use the injected random
API (random.D6(), random.Shuffle(deck), …). It is seeded and
deterministic, which is required for replay, undo, and bots.
- In every move, either mutate
G and return nothing, or return a new G
— never both. Both styles are valid; mixing them is an error.
- Moves must be pure and side-effect free. No network calls, no
console.log on hot paths, no reading external state. Same inputs → same
result.
- Guard
ctx.activePlayers — it is null when no stage is active.
- Player IDs are strings (
'0', '1', …). Key per-player state by
string: G.players[ctx.currentPlayer].
- Reject illegal moves with
INVALID_MOVE (from boardgame.io/core)
instead of throwing, and make invalid input a no-op in UI handlers.
The v0.50 API shape (memorize this)
Every callback receives a single destructured first argument:
moveName: ({ G, ctx, playerID, events, random }, ...args) => { ... }
(Pre-0.50 code passing (G, ctx) as two arguments is outdated — don't
generate it, and update it when you see it.)
Workflow: build a game in 5 steps
Step 1 — Clarify the game and design G
Before writing code, list: players, board/pieces representation, one move
type per player action, turn order, and win/lose/draw conditions. Choose the
simplest serializable G that captures it. If unsure, sketch G and the
moves as a short plan first.
Step 2 — Define game logic (src/game.js)
import { INVALID_MOVE } from 'boardgame.io/dist/esm/core.js';
export const MyGame = {
name: 'my-game',
setup: ({ ctx, setupData }) => ({
cells: Array(9).fill(null),
}),
moves: {
clickCell: ({ G, playerID }, id) => {
if (G.cells[id] !== null) return INVALID_MOVE;
G.cells[id] = playerID;
},
},
turn: { minMoves: 1, maxMoves: 1 },
endIf: ({ G, ctx }) => {
if (isVictory(G.cells)) return { winner: ctx.currentPlayer };
if (G.cells.every( c !== )) { : };
},
};
Victory/draw helpers (isVictory above) are plain functions kept in the same
file. For anything beyond a single phase — draws, auctions, simultaneous
reveals — read references/game-definition.md for phases, stages,
setActivePlayers, turn orders, and the events API.
Step 3 — Build the board UI (src/board.jsx)
A React component that receives everything as props. It renders G/ctx and
calls props.moves.*. Never compute game logic in the UI — only
display-level checks (e.g. dimming inactive cells):
const Board = ({ G, ctx, moves, isActive, playerID }) => (
<div>
{ctx.gameover ? (
<div>{ctx.gameover.winner != null ? `Winner: ${ctx.gameover.winner}` : 'Draw!'}</div>
) : (
<div>Turn: player {ctx.currentPlayer}</div>
)}
<table id="board">
<tbody>
{[0, 1, 2].map((row) => (
<tr key={row}>
{[0, 1, 2].map((col) => {
const id = 3 * row + col;
return (
<td key={id}
onClick={() => isActive && G.cells[id] === null && moves.clickCell(id)}>
{G.cells[id]}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
export default Board;
Full list of board props: G, ctx, moves, events, reset, undo,
redo, previewState, loadState, isActive, isMultiplayer,
isConnected, isPreview, playerID, matchID, matchData,
credentials, log, chatMessages, sendChatMessage.
Read ctx.gameover to detect game end.
Step 4 — Wire the client (src/index.jsx)
Pick the mode by how the game is played:
import { Client } from 'boardgame.io/react';
import { Local, SocketIO } from 'boardgame.io/multiplayer';
import MyGame from './game';
import Board from './board';
const App = Client({ game: MyGame, board: Board, debug: true });
With multiplayer set, a client without playerID is a spectator (sees
state, cannot move) — every interactive view needs its seat. isActive is
true only when the local player may act now — gate every interactive
element on it.
Step 5 — Add server + tests (as needed)
A hot-seat or Local() game needs no server at all. For online play:
const { Server, Origins } = require('boardgame.io/server');
(async () => {
const { Game } = await import('./src/game.js');
const server = Server({ games: [Game], origins: [Origins.LOCALHOST_IN_DEVELOPMENT] });
server.run(8000, () => console.log('http://localhost:8000'));
})();
For online play with match creation/joining, read
references/client-server.md (lobby API).
Import paths — bundler vs plain Node. boardgame.io@0.50.x has no Node
exports map. Subpaths like boardgame.io/client / /react /
/multiplayer / /core resolve fine in bundlers (Vite/webpack) but fail in
plain Node with ERR_UNSUPPORTED_DIR_IMPORT. In Node-run code (tests,
scripts) — and in any file they import, which is typically game.js —
use the deep ESM builds instead: boardgame.io/dist/esm/client.js,
boardgame.io/dist/esm/core.js, … (works in Vite too). The server is
CJS-only: use require('boardgame.io/server') from a .cjs file (the
template's server.cjs shows how). Browser-only files (index.jsx,
board.jsx) can use the normal subpaths.
Static assets (card art, sprites) follow the same rule. Never
import img from './card.jpg' in a module that plain-Node tests also load
(cards.js, game.js) — Node dies with ERR_UNKNOWN_FILE_EXTENSION.
Use Vite's URL asset pattern, which both worlds accept:
const img = (file) => new URL(`./assets/cards/${file}`, import.meta.url).href;
export const CARDS = { wukong: { ..., img: img('wukong.jpg') } };
Render it as a background <img> on the card component. For generating
card art with an image-generation tool: batch all cards with one shared
style prompt (consistent look), save to src/assets/cards/, and reference
via the helper above.
Test game logic headless — no React needed (details in
references/advanced.md):
import { Client } from 'boardgame.io/dist/esm/client.js';
import MyGame from './src/game.js';
test('first move fills the cell', () => {
const client = Client({ game: MyGame });
client.moves.clickCell(0);
const { G } = client.getState();
expect(G.cells[0]).toBe('0');
});
Building a NEW game: copy, don't rewrite (CRITICAL)
The #1 mistake when building a new boardgame.io game is rewriting the
engine from scratch. This has failed every time it was attempted. Instead:
- Start from a working game (journey-west or card-game template)
- Only change the theme (cards, names, story text, CSS colors)
- Keep the engine architecture identical: same
game.js structure,
same bot.js pattern, same board.jsx layout, same index.jsx setup
- Test in a real browser after EVERY change — unit tests passing
does NOT mean the browser experience works
The proven architecture for single-player vs AI:
index.jsx: multiplayer: Local() + createBossClient (bot subscribes
and acts on player 1's turn)
game.js: endIf for victory, playerView for hidden info,
turn.onBegin for draw/reset, events.endTurn() for turn cycling
bot.js: scripted AI that calls client.moves.* on its turn
- Removing
multiplayer: Local() kills the AI opponent — never do this
to "fix" click issues (the click issue is elsewhere)
Story mode as a wrapper: Use G.mode ('menu' | 'story' | 'battle')
to switch UI views in board.jsx. The battle engine doesn't know about
story — it's purely a UI layer.
Ready-made project skeleton
Two ready-made skeletons live under assets/templates/ (both vite +
react, with tests and an optional server). Copy the matching one and edit
its game definition/board — prefer a template over inventing scaffolding:
| Template | Use for | Demonstrates beyond the basics |
|---|
minimal/ | board games, quick starts (tic-tac-toe) | moves, INVALID_MOVE, turn limits, endIf, one-click tests |
card-game/ | card games, hidden hands/decks, combat | playerView secret state, random.Shuffle, client: false spells, G.step turn sub-phases, status-line feedback for every action, restart button, full-game auto-play tests (read its README) |
For any game with hidden information or per-turn sub-phases, start from
card-game/ — its patterns (secret state, one-click UX, feedback on
illegal actions) are already battle-tested.
Reference routing — read on demand
| Need | File |
|---|
Phases, stages, setActivePlayers, custom turn order, events, bots/AI, long-form moves, playerView/secret state | references/game-definition.md |
| React client options, plain JS client, SocketIO multiplayer, server config, lobby (match create/join), persistence | references/client-server.md |
| Random API, undo strategy, testing patterns, TypeScript, debug panel, performance | references/advanced.md |
| Sound effects, animations, action showcase/narration, game feel (esp. for hidden-info or AI-opponent games) | references/game-feel.md |
Read the relevant reference before writing code that uses those
features — exact option names and hook support matrices matter.
Common pitfalls checklist
Before finishing, verify:
UI playability checklist (learned the hard way)
A correct game definition can still be unplayable if the board UI wires
clicks badly. Before calling a game "playable":
Game feel checklist (for games humans will actually enjoy)
Read references/game-feel.md before adding sound/animation/showcase work.
The non-negotiables: