Documentation

The client

v2 · Stable

Send clicks and handle failures

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.
The preview toolbar includes restart, player-view, and checkpoint controls. Available controls depend on the framework.
The preview toolbar includes restart, player-view, and checkpoint controls. Available controls depend on the framework. Example data from the local development UI. Select the image to enlarge.

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.

Need a hand with this?

Join our Discord