Read match state
useMatch()
const match = useMatch();
match.state // BaseGameStateWithIds — the optimistic shared state
match.click(action, options?) // → ActionId. The only way to change shared state.
match.pending // readonly PendingClick[] — in-flight clicks, oldest first
match.failures // readonly ClickFailure[] — recent failures, oldest first
match.online // boolean — is the transport connected?
match.status // "waiting" | "in_progress" | "finished" | "locked"
match.abandonedPlayerIds // readonly PlayerId[] — seats that left early
match.isHost // boolean: may this player start a rematch?
match.isSpectator // boolean: watching without a seat, cannot move
match.nextMatchPending // boolean: is a rematch you asked for still starting?
match.dismissFailure(actionId)
match.clearFailures()
match.requestNextMatch() // ask the platform for a rematch (host only)
match.returnToGames() // ask the platform to leave this match
match.state is never null in author code : the runtime withholds your first render until the initial snapshot arrives, so no loading guard is needed.
It is also reference-stable: each incoming snapshot is reconciled into the previous one keyed by pieceId / spaceId / playerId, so objects on unchanged subtrees keep their identity. That is what makes memo effective (see the memoization contract below).
Treat it as deeply read-only. Shared state changes only through click.
Derived hooks
All of these compute from optimistic state and are memoized on (match.state, viewingPlayerId) : they recompute only when the state object itself changes, and otherwise return the same reference.
| Hook | Returns |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
| useGameState<MetaData>() | Rich def-backed GameState : game.piece(id), game.spaces("kind"), game.metaData, the same API /src/game.ts uses. |
| useSelf() | { playerId, isActive } : who is viewing, and whether it's their turn. |
| usePlayers() | All players in seating order. Other players' privateState is null (scrubbed server-side). |
| useScores() | Your getPlayerScores on optimistic state, keyed by playerId. |
| useIsGameOver() | Your isGameOver on optimistic state. |
| useButtons() | Your getButtons for the viewing player. Render disabled ones inert : clicking fails with INVALID_ACTION. |
| useAvailableActions() | Your getAvailableActions : every legal action right now, each already a ClickAction plus its intent ("choice" \| "confirm" \| "cancel" \| "undo") and optional label. |
| useSelectableItems() | Legacy. Your getSelectableItems : legal click targets only. Empty for a game that defines getAvailableActions. |
| useImage(key) | A URL for <img src>. key is a GameImage member. |
| useSounds() | The game's sound player: play(key, { type: "music" \| "effect", loop?, volume? }) and stopAll(type?). key is a GameSound member. See v2-sounds. |
| useAudioSettings() | { music, effects }: what the viewer hears right now. useSounds already applies it. |
| useGameTheme() | The validated /theme.json document. |
| useViewport() | { width, height } of the frame, in CSS pixels. Re-renders on change. |
| useColorMode() | "light" | "dark" : the viewer's preference. |
Derived values are produced by calling your own backend functions on optimistic state : the same code the server runs. You never duplicate rules on the client.
useAvailableActions() : the legal moves, ready to click
Each entry's action is already a ClickAction, so there is no id to look up and no shape to translate:
const available = useAvailableActions();
// Index by target to drive the board...
const bySpace = useMemo(() => {
const map = new Map<string, ClickAction>();
for (const { action } of available) {
if (action.spaceId !== undefined) map.set(action.spaceId, action);
}
return map;
}, [available]);
// ...and filter by intent to drive the controls.
const commits = available.filter((a) => a.intent === "confirm");
intent is where the extra information lives: confirm actions are your commit controls, cancel and undo your two different kinds of back button, and choice the clicks that only advance a decision in progress. See v2-hooks for how the game picks them.
Where hooks work
Every hook reads runtime-owned context. Called outside the runtime's root, they throw rather than returning placeholder data:
boardweaver/react hooks must be rendered inside the Boardweaver client runtime
The re-render contract
useMatch() subscribes your component to every match change : new snapshots, optimistic applies and rollbacks, pending/failure transitions, and connectivity. There is no selector form.
Performance comes from the reference-stability guarantee plus standard React tools: structure components so leaves take pieces[i] / spaces[i] objects as props, and wrap those leaves in memo. Because unchanged subtrees keep object identity across snapshots, memoized leaves genuinely skip re-rendering.
match itself is a fresh object per version, but its methods (click, dismissFailure, clearFailures) are referentially stable for the match's lifetime : safe in useCallback / useEffect dependency arrays.
Guard stored ids
Client state routinely holds a pieceId or spaceId, and a snapshot can remove that entity at any time:
const piece = game.piece(storedId);
if (!piece) return null; // always possible — check it
game.piece(id) and game.space(id) are always | undefined.