A v3 game's client is /src/frontend.tsx: one default export, a component taking no props. It imports from boardweaver/react, and is served the view of the state this player may see, so it can only draw what they are allowed to know.
import { useAvailableActions, useMatch, useSpace } from "boardweaver/react";
export default function App() {
const match = useMatch();
const hand = useSpace(`hand-${match.state.activePlayerIds[0]}`);
const playable = new Set(
useAvailableActions().flatMap(({ click }) =>
click.pieceKey === undefined ? [] : [click.pieceKey]
)
);
return (
<ol>
{hand.pieces.map((card) => (
<li key={card.viewKey}>
<button
disabled={!playable.has(card.viewKey)}
onClick={() => match.click({ type: "Click", pieceKey: card.viewKey })}
>
{card.privateKind ?? card.publicKind}
</button>
</li>
))}
</ol>
);
}
A v3 piece has no id. It lives in its space's pieces, and the frame gives it a viewKey: use it as the React key, as the drag id, and to click the piece.
publicKind and publicState are always there.privateKind and privateState are there only when this player may see them, so read them with ?? or a guard. A face-down card in someone else's hand simply has neither.viewKey is an identity consistent with what this player can see. It follows a piece while it looks the same to them, and while they can read what it is. Two cards a player cannot tell apart may swap keys, which is invisible because they render the same. Do not store one as a lasting name for a card.| Hook | What it gives |
|---|---|
useMatch() |
The state this player sees, click, the pending queue, failures, online, the match lifecycle, undo and undoableActionId |
useSelf() |
{ playerId, isActive } |
usePlayers() |
Every player, in seating order |
useSpace(spaceId) |
One space and the pieces in it, in the order this player is shown them |
useAvailableActions() |
Every legal move as a click, with its intent and the game's label |
useButtons() |
The buttons among those moves |
useScores(), useIsGameOver() |
The game's own answers for the state on screen |
useLastChange() |
What changed in the last snapshot, for animation: piece viewKeys in pieceIds, plus spaceIds, playerIds, who acted, and whether it was one action's worth |
useGameLog() |
The game log, in this player's own words |
useImage(), useGameTheme(), useViewport(), useColorMode() |
The same as in v2 |
match.click takes one target: { pieceKey }, { spaceId } or { buttonId }.
Collection. The pieces this player cannot see arrive grouped after the ones they can, so hand.pieces.length is still the real count, but the order of the hidden ones says nothing. Render them as backs.match.pending is not empty if you want to avoid the refusal.match.undoableActionId is the id of this player's own last move while they may take it back, and null otherwise: after someone else moves, while one of their clicks is pending, and for spectators. match.undo() takes it back. Nothing is predicted; the board changes when the server answers.
<button disabled={match.undoableActionId === null} onClick={() => match.undo()}>
Undo
</button>
The UI and animation libraries (boardweaver/ui, boardweaver/motion, boardweaver/dnd, boardweaver/immer), images through useImage, the theme, and the rule that the client never decides the rules: it reads state and sends clicks, and the server decides what they mean. What is gone is useGameState and the def registry: a v3 client reads the state the hooks give it, and sizes and art are its own business.