Documentation

The client

v2 · Stable

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.

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.

Need a hand with this?

Join our Discord