boardweaver/react gives you the running match. It owns exactly what you cannot build yourself — the server snapshot stream, optimistic application of in-flight clicks, reconcile, ack/reject handling, and connectivity — and deliberately nothing else.
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.dismissFailure(actionId)
match.clearFailures()
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.
There is exactly one shared action, carrying exactly one target id:
match.click({ type: "Click", pieceId: piece.pieceId });
match.click({ type: "Click", spaceId: space.spaceId });
match.click({ type: "Click", buttonId: button.id });
An object carrying two target ids is a compile error. A structurally malformed action throws a synchronous TypeError — that's an author bug, not a game-rules refusal.
A click is locked in. There is no takeback; only game rules or a server rejection reverse it. What happens on click:
applyActions locally.INVALID_ACTION — never enqueued, never sent.match.pending and goes to the transport.pending) or rejects it (its optimistic effect is rolled back).const actionId = match.click(
{ type: "Click", spaceId },
{
onResult: (result) => {
if (result.status === "failed") {
// result.failure.error.code is "INVALID_ACTION" | "REJECTED"
// result.failure.error.message is toast-ready
}
},
},
);
onResult fires exactly once, always asynchronously — never during the click call itself. Use it for click-site handling, like unsealing an undo barrier.
Failures also land in match.failures whether or not you passed onResult, so a global toast layer can render that list directly.
| Code | Phase | Meaning |
|---|---|---|
INVALID_ACTION |
optimistic |
The action wasn't in getAvailableActions for the current state (or, for a legacy game, not in getSelectableItems / getButtons), applyActions threw, or the match is no longer in_progress. Never reached the server. |
REJECTED |
server |
The server refused it, or the send failed. Optimistic effect rolled back. |
A click made while match.online is false is queued, not failed. It applies optimistically, stays in match.pending, and is sent as soon as the connection returns — a brief drop is invisible to the player.
So you do not need to block input when offline. What match.online is for is telling the player what is happening: show an offline indicator, and let match.pending show how many moves are waiting to send.
{!match.online && (
<p>Offline — {match.pending.length} move(s) will send when you reconnect.</p>
)}
Two things this does not change. A queued click can still be rejected on arrival if the game moved on while you were away — you get a normal failed result, just later than usual. And a click that was already in flight when the connection dropped is a different case: the runtime holds its result open until it can confirm what the server actually did, so it stays in pending rather than resolving either way.
match.status is the platform's answer to "is this match still being played". Only "in_progress" accepts moves — while it is anything else every click fails locally with INVALID_ACTION and is never sent, because the server refuses it too.
That is a different question from useIsGameOver(), which is your isGameOver rule. A match reaches "finished" when the rules end it or when a seated player abandons it, so a match can be over with useIsGameOver() still false:
if (match.status !== "in_progress") {
const quitters = match.abandonedPlayerIds;
return quitters.length > 0
? <MatchEnded reason="abandoned" playerIds={quitters} />
: <Results scores={scores} />;
}
By default the platform paints its own "Match ended" panel over your board once this leaves "in_progress", so you get a correct ending for free. If you want to render it yourself, ask an admin to set rendersMatchEnd on the game (update_game) — the platform then leaves the surface entirely to you, which means you must handle every non-"in_progress" status, or players are stranded on a board that looks playable and isn't.
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. |
useGameTheme() |
The validated /theme.json document. |
useViewport() |
{ width, height } of the frame, in CSS pixels. Re-renders on change. |
useColorMode() |
`"light" |
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 clickEach 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.
useLastChange()Your own clicks are fully observable: click() returns an id, onResult gives the verdict, match.pending is the queue. An opponent's move is not — you receive a whole new snapshot with no note saying what happened. useLastChange() is that note.
const { actorPlayerId, pieceIds, self, coalesced } = useLastChange();
useEffect(() => {
if (coalesced) return; // not one move — don't animate through it
for (const id of pieceIds) flashPiece(id);
}, [pieceIds, coalesced]);
| Field | Meaning |
|---|---|
actorPlayerId |
Who moved, or null when the change isn't attributable to one action. |
appliedActionId |
Which action, or null — same cases. |
self |
The change came from a click this client sent. |
pieceIds / spaceIds / playerIds |
Entities whose object identity changed. Reconcile keeps untouched entities reference-equal, so this is the change set — not a diff you have to compute. |
coalesced |
This snapshot isn't one action. Animate to it, not through it. |
The returned object is a stable reference per snapshot, so it is safe as a useEffect dependency: the effect fires once per change, not once per render.
Four things to get right, because each is a way a naive diff-and-animate breaks:
null attribution. It is a normal value, not an edge case. A reconnect resync, a lifecycle-only broadcast, and the very first snapshot all have no single actor — and the platform reports null rather than naming the most recent player, because naming one for a delta that may hold several moves reads as a certainty it doesn't have.coalesced marks. Make your animation layer interruptible and idempotent rather than a replay queue — a queue that assumes 1:1 desynchronizes permanently the first time that assumption breaks.self with a non-empty id set is meaningful. Normally your own move arrives with empty lists: the optimistic apply already painted it, so nothing changed. If your applyActions is nondeterministic (a shuffle, a draw), the authoritative result differs from your prediction, and those entities are the server correcting you — usually worth rendering differently from an opponent's move.metaData, not from the diffuseLastChange() tells you what changed, never what it meant. "Piece moved A→B" is derivable; "that was a capture, play the capture animation" is not, and the platform cannot invent it — only your game knows. There is no event stream. The pattern is a breadcrumb your own applyActions leaves:
// /src/game.ts
export const applyActions: ApplyActionsFn<MetaData> = (state, action) => {
// ... your rules ...
state.metaData.lastEvent = { kind: "capture", pieceId: captured.pieceId };
};
// /src/frontend.tsx
const game = useGameState<MetaData>();
const { appliedActionId } = useLastChange();
useEffect(() => {
// Null on any coalesced snapshot. The breadcrumb still holds the PREVIOUS
// action's event, so reading it here would replay that animation.
if (appliedActionId === null) return;
const event = game.metaData.lastEvent;
if (event?.kind === "capture") playCaptureAnimation(event.pieceId);
}, [appliedActionId]);
Key the effect on appliedActionId rather than on the breadcrumb's contents: two identical events in a row (the same capture twice) are the same value, and an effect keyed on the value would miss the second one.
The null guard is not optional. appliedActionId goes "2-7" → null → "2-8", because coalesced snapshots come in between attributed ones and a lifecycle-only re-broadcast (a player renames, a seat is abandoned) is exactly that. Without the guard the dependency changes twice per action and the same capture animation plays twice.
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
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.
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.