Have more questions? Join our

The match API

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.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.

Clicks — the one server verb

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:

  1. The runtime optimistically applies it by running your game's own applyActions locally.
  2. If that refuses it, the click fails immediately with INVALID_ACTION — never enqueued, never sent.
  3. Otherwise it lands on match.pending and goes to the transport.
  4. The server either acks it (it leaves pending) or rejects it (its optimistic effect is rolled back).

Terminal results

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.

A third status, ignored, means the click was never attempted. Today the only reason is "spectator". An ignored click sends nothing, changes nothing, and deliberately does not appear in match.failures, so a game that renders that list as toasts stays quiet for a watcher.

An ignored result still carries a failure, with the code SPECTATOR, so a handler that reads result.failure.error.message on everything that is not committed keeps working and shows a sensible message. Branch on result.status === "ignored" when you want to say something of your own, or nothing at all.

Spectators

A match host can open a match to people who hold the link but no seat. They receive the same scrubbed state every non-player sees: no hands, no face-down cards, no private spaces.

match.isSpectator is the signal to gate on, and it is the only one. Do not try to infer a seatless viewer from the viewing player id. A spectator is projected with the reserved id -1. That collides with nothing the platform issues, because account ids are positive, but it can collide with a player your own preGameInitialization seated. A game that fills empty seats with bots and numbers them -1 and -2 will find that a spectator's useSelf().playerId resolves to one of those bots, usePlayers() finds a match for it, and every "is this me?" check in the UI answers yes for that seat: the watcher is told they are that player, in that color, with that hand.

Never assign a player id that is zero or negative. Ids you invent for bots or neutral seats have to stay clear of the positive account range and of the reserved -1, so number them from a positive range of your own.

match.click() refuses a spectator outright, so shared state is safe whether or not you do anything. The UI is not. A game that ignores spectators leaves a watcher looking at controls that appear live and do nothing, client-side timers that keep firing, and copy that addresses them as a player. All three read as bugs, to spectators and to the players sitting next to them. Render for a spectator deliberately:

Prefer one branch over a gate at each call site. Where your controls sit beside the board, the spectator path simply does not render them, so a control added to the player path a year from now is spectator-safe without anyone remembering this rule:

const match = useMatch();

if (match.isSpectator) return <Board />;

return (
  <Board>
    <TurnControls />
  </Board>
);

Most games are not shaped like that. When the board itself is the control surface, because pieces are dragged or cards are clicked in place, there is nothing to omit: the affordance and the presentation are the same element. Pass the viewer's status down instead, and let each interactive element read it in one place:

const match = useMatch();

return <Board interactive={!match.isSpectator} />;

Either shape works. What matters is that a spectator's board is produced by a decision made once, rather than by every control remembering to check.

Hover, scrolling and any purely local UI keep working for a spectator, so an inspect-only interaction (zooming a card, opening a log) is still yours to offer.

What hides itself, and what does not

Two kinds of control behave very differently for a spectator, and the difference tells you where your bugs will be.

Controls you render from useButtons(), useAvailableActions() or useSelectableItems() are self-hiding. Those hooks return empty for a spectator without calling your game at all, so the controls have no data to render from. That happens whether or not you wrote a single line of spectator code.

Your getButtons and getAvailableActions are therefore never invoked on a spectator's client. That is deliberate, and it is why you do not have to make them seat-agnostic: the GameState a spectator's client would hand them carries the reserved viewer id as currentPlayerId, so state.currentPlayer would match nobody and throw. Optional chaining does not save you, because the throw is inside the getter, not a null return.

Controls you render from your own client state have no backstop at all. A local toggle, a right-click or keyboard gesture, a useEffect on a timer, a line of copy: nothing on the server sits between these and the watcher, and your branch is the only thing holding them back. These are the ones that break, so check them first and individually.

Two that are easy to misfile:

  • A local-looking preference that submits. "Auto-pass", "auto-roll", "auto-confirm", "keep doing X" read like view settings and are really move submitters on a checkbox. Trace a toggle to what it actually calls before deciding it is harmless.
  • Effects and timers that reach match.click. A bot-turn handoff or an auto-advance on a setTimeout runs on a spectator's client exactly as it runs on a player's. A watcher must never be the client that drives the game forward.

Verify as a spectator, not with unit tests

Unit tests over your own gate predicates pass whether or not the value you feed them is correct, so a green suite is not evidence that spectators see the right thing. Look at a spectator's screen before calling this done.

The studio's Game Preview is the quickest way there: its viewer picker lists "View as spectator" next to the seats, and picking it boots a frame with match.isSpectator true, the reserved viewer id, and the same scrubbed state a watcher is sent, so an opponent's hand that shows up there would show up in a real match too. Switch back to a seat to take a turn: nobody can move while you are watching, which is exactly the constraint a spectator lives under.

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.
SPECTATOR optimistic The viewer holds no seat. Arrives as status: "ignored", never reaches the server, and is the one code that stays out of match.failures.

Offline

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.

When the match ends

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, set rendersMatchEnd on the game — either via update_game, or from the studio's Code view by opening /manifest.json, which is a form over the same field. 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.

Getting players out of a finished match

The panel you suppressed carried the only two ways out of a match, so a game that owns the ending has to offer them itself:

<button onClick={() => match.returnToGames()}>Return to games</button>;
{
  match.isHost ? (
    <button
      disabled={match.nextMatchPending}
      onClick={() => match.requestNextMatch()}
    >
      {match.nextMatchPending ? "Starting…" : "Next match"}
    </button>
  ) : (
    <p>Waiting for the host to start a new match…</p>
  );
}

returnToGames() leaves the match for the play screen.

requestNextMatch() starts a rematch: a new match on the same game and version. Only the host may ask (match.isHost); the server refuses it from anyone else, and refuses it at all until the match is finished.

Both calls are ignored while match.status is still "in_progress", So wire them to the same branch that renders your ending, not to a button that's live mid-play.

Creating a rematch is a round trip, so match.nextMatchPending is true from your request until the new match exists or the attempt fails. Render it: without it your button looks inert for that beat and players click it again. On success everyone is navigated into the new match, so the flag going back to false is what you see when it failed, and the platform has already shown the player why.

Both are requests to the platform, not things your frontend does. Your game runs in a sandboxed frame with no top-level navigation, and self-navigating trips containment and locks the match, so these two calls are the whole set of exits available to you.

In the studio preview and the simulator there is no match behind the frame. match.isHost reads false there and both calls do nothing.

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 GameStategame.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" | "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.

Animating an opponent's move — 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:

  • Handle 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.
  • One broadcast is not one action. A resync or a mid-game join folds several actions into one delta; that is what 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.
  • Removed entities are not listed. They aren't in this snapshot, so their ids wouldn't resolve. Animate exits from the entity unmounting.

Semantics come from metaData, not from the diff

useLastChange() 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.

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.