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:
Gmust be JSON-serializable. Plain objects, arrays, numbers, strings, booleans, null. No class instances, no functions, noundefinedinside arrays. Use IDs/indices instead of references.- Never touch
ctx. It is framework-managed. To end a turn, change phases, etc., callevents.*(see below). In moves, readctx(ctx.currentPlayer,ctx.numPlayers,ctx.playOrder,ctx.phase,ctx.activePlayers), never write it. - Never use
Math.random()orDate.now(). Use the injectedrandomAPI (random.D6(),random.Shuffle(deck), …). It is seeded and deterministic, which is required for replay, undo, and bots. - In every move, either mutate
Gand return nothing, or return a newG— never both. Both styles are valid; mixing them is an error. - Moves must be pure and side-effect free. No network calls, no
console.logon hot paths, no reading external state. Same inputs → same result. - Guard
ctx.activePlayers— it isnullwhen 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(fromboardgame.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:
// moves, setup, hooks, endIf, playerView — all use this shape
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)
// deep import so this file also loads under plain-Node tests (see Step 5)
import { INVALID_MOVE } from 'boardgame.io/dist/esm/core.js';
export const MyGame = {
name: 'my-game', // unique name (used by server/lobby)
setup: ({ ctx, setupData }) => ({ // returns initial G
cells: Array(9).fill(null),
}),
moves: {
clickCell: ({ G, playerID }, id) => {
if (G.cells[id] !== null) return INVALID_MOVE;
G.cells[id] = playerID; // mutation style; return nothing
},
},
turn: { minMoves: 1, maxMoves: 1 }, // auto-end turn after 1 move
endIf: ({ G, ctx }) => { // truthy return ends the game
if (isVictory(G.cells)) return { winner: ctx.currentPlayer };
if (G.cells.every((c) => c !== null)) return { draw: true };
},
};
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';
// Hot-seat (both players share this one screen): NO multiplayer option.
// One <App/> renders the shared view; the framework ends each player's
// turn automatically. Simplest mode — start here.
const App = Client({ game: MyGame, board: Board, debug: true });
// Two separate views (two panels/tabs, no server): Local() + a seat per instance.
// const App = Client({ game: MyGame, board: Board, multiplayer: Local() });
// <App matchID="m" playerID="0" /> <App matchID="m" playerID="1" />
// Online (needs the server from step 5):
// const App = Client({ game: MyGame, board: Board,
// multiplayer: SocketIO({ server: 'localhost:8000' }) });
// <App matchID="xyz" playerID="0" credentials="..." />
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:
// server.cjs — CommonJS on purpose (see "Import paths" note below)
const { Server, Origins } = require('boardgame.io/server');
(async () => {
const { Game } = await import('./src/game.js'); // game stays ESM
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):
// runs under plain Node — note the deep import (see box above)
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.jsstructure, samebot.jspattern, sameboard.jsxlayout, sameindex.jsxsetup - 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:endIffor victory,playerViewfor hidden info,turn.onBeginfor draw/reset,events.endTurn()for turn cyclingbot.js: scripted AI that callsclient.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:
- No
Math.random()/Date.now()/ side effects in moves or hooks. - No writes to
ctx; turn/phase changes go throughevents.*. - Every move either mutates
Gsilently or returns a newG— never both. - Phase-level
movesreplace global moves (they don't merge) — re-list or re-export shared moves. -
ctx.activePlayersguarded againstnull. - Ending a phase implicitly ends the current turn first — don't fight it.
(For per-turn sub-steps like draw/main/battle, track a
G.stepfield with move guards instead of using flow phases.) - Random-dependent moves are
client: falseor rely on the framework's optimistic-update discard (random makes moves server-authoritative automatically). - Dice rolls / card draws that reveal information are
undoable: false(undo would let players peek and reroll). - UI interactivity gated on
isActive; game over handled viactx.gameover. - Each game on a server has a unique
name.
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":
- Every clickable element has its handler attached in all states —
e.g. empty board slots need
onClicktoo, or summoning is impossible. (Bug seen:onClick={entry ? handler : undefined}on slot arrays.) - Every rejected action shows visible feedback.
INVALID_MOVEis silent; mirror the move guards in the board and display the reason in a status line. Never leave a click without a reaction. - Prefer one-click actions. Click hand card = summon to first empty slot / activate spell. Multi-step select-then-place flows confuse players and multiply wiring bugs.
- Disable the debug panel (
debug: false) for playable builds — it floats above the board and intercepts real clicks. - No hover transforms (
translateYetc.) on clickable cards — the element moves under the pointer and breaks both humans and automation. Use border/glow effects instead. - Fit one screen. Players use small panes; controls below the fold look like dead buttons. Compact cards/paddings and verify total page height against a ~900px viewport.
- Offer a restart / new game control (remount the client with a new
matchID+key), or players are stuck in a finished match.
Game feel checklist (for games humans will actually enjoy)
Read references/game-feel.md before adding sound/animation/showcase work.
The non-negotiables:
- Every important action gets an on-screen notification — players do not read battle logs. Confirmed tiered scheme: card-duel popup (both cards + outcome badges) for monster battles, card popup for opponent summons/board spells, text popup for everything else (direct attacks, draws, heals). One popup per event, never queued, never delayed sequences; match AI pacing to popup duration.
- SFX: Web Audio synthesis (zero assets), unlocked on first pointer interaction, mute persisted; BGM separate toggle.