This documents framework v1, which is deprecated. Games already on it keep running and their authors can keep editing them, but new games are created on the current framework — start with React frontends.
GameState API
GameState<MetaData> is the live, mutable view of the game passed to every hook. It exposes query methods (returning Player / Piece / Space instances) and mutation methods (addPiece, addSpace, settable props).
import { ApplyActionsFn } from "boardweaver";
export const applyActions: ApplyActionsFn = (state, action) => {
const me = state.currentPlayer; // Player
const myHand = me.ensureSpaceOfKind("hand"); // Space
const top = myHand.pieces("card")[0]; // Piece | undefined
if (top) top.spaceId = state.ensureSpace("discard").spaceId;
state.activePlayerIds = [otherPlayerId(state)];
};
Top-level state
| Property | Type | Mutable? |
|---|---|---|
currentPlayer |
Player<MetaData> |
no (derived from session) |
activePlayerIds |
number[] |
yes — controls whose turn it is |
metaData |
MetaData (defaults to Record<string, unknown>) |
yes |
scoreLabels |
string[] | undefined |
yes |
defRegistry |
DefRegistry |
no (framework-built) |
scoreLabels names each entry of the points arrays your getPlayerScores returns, in order. The labels head the columns of the final scores table that players see at the end of a match and in their Recently played history, and of the score table on the studio Matches page. Studio Stats also uses them for "Points by category", which compares how many points winners and everyone else score in each label. Points are matched to labels by name, so keep a label's name stable across versions to compare it over time. An entry with no label is shown as "Score 1", "Score 2", and so on.
Lookups: selectors
Every query method accepts a selector:
string— meaning depends on the method. On the singular lookups (piece/space/player,ensurePiece/ensureSpace/ensurePlayer) a bare string matches by id (pieceId/spaceId/playerId). On the plural lookups (pieces/spaces/players) a bare string matches by kind —state.pieces("x-token")returns every piece whosekindis"x-token", not a piece with id"x-token".- predicate
(item) => unknown— truthy keeps it.
Kind-typed overloads take a kind first, then a selector (string id or predicate) that sees the narrowed item. Predicates that return undefined are treated as false.
Pieces
state.pieces(); // Piece[]
state.pieces((p) => p.spaceId === "0"); // Piece[] (by predicate)
state.pieces("x-token"); // Piece[] narrowed to XToken
state.pieces("x-token", (p) => !p.isSelected); // Piece[] narrowed
state.piece("p1"); // Piece | undefined (by id)
state.piece("x-token", (p) => p.order === 1); // narrowed | undefined
state.ensurePiece("p1"); // throws if missing
state.ensurePiece("x-token", (p) => p.order === 1); // throws if missing
All pieces() results are sorted ascending by piece.order.
Spaces
state.spaces();
state.spaces((s) => s.x < 200);
state.spaces("grid-cell");
state.spaces("grid-cell", (s) => s.pieces().length === 0);
state.space("0");
state.space("grid-cell", (s) => s.x === 0 && s.y === 0);
state.ensureSpace("0");
state.ensureSpace("grid-cell", (s) => s.x === 0 && s.y === 0);
// Kind-only helpers (no selector) for the common "one of this kind per scope" case:
state.spaceOfKind("grid-cell"); // first match, or undefined
state.spacesOfKind("grid-cell"); // all matches
state.ensureSpaceOfKind("grid-cell"); // throws if missing
Players
state.players(); // Player[]
state.players((p) => p.playerId !== state.currentPlayer.playerId);
state.player(0); // by playerId (number)
state.player((p) => p.username === "alice");
state.ensurePlayer(0); // throws if missing
There are no kind-typed overloads for players (no PlayerKindRegistry).
Mutation
addPiece(pieceId, def)
const piece = state.addPiece("p123", new XToken({ order: 1 }));
piece.spaceId = "0"; // attach to a space
Throws if pieceId collides or def.kind isn't registered (see pieces).
space.addPiece(pieceId, def) is shorthand that sets piece.spaceId = space.spaceId.
addSpace(spaceId, def)
const space = state.addSpace("s123", new GridCell({ x: 0, y: 0 }));
space.playerId = state.currentPlayer.playerId;
Throws if spaceId collides or def.kind isn't registered (see spaces).
player.addSpace(spaceId, def) is shorthand that sets space.playerId = player.playerId.
Setting properties
Most fields on Piece, Space, Player, GameState have setters — see api-reference for the full list. Common mutations:
piece.spaceId = newSpace.spaceId;
piece.order = 5;
piece.isSelected = true;
piece.currentOrientationIndex = 1;
piece.publicState = { ...piece.publicState, tapped: true };
space.isHidden = true;
space.type = "Horizontal";
player.notification = { title: "Your turn", intent: "info" };
state.activePlayerIds = [nextPlayerId];
state.metaData = { ...state.metaData, phase: "combat" };
publicState and privateState setters take the full object — mutating an individual key in place works too, but the setter is the safe form when you need to pin a shape.
Player-scoped lookups
Player has the same piece / pieces / space / spaces / *OfKind methods, scoped to that player's owned spaces and the pieces inside them.
const me = state.currentPlayer;
me.pieces(); // pieces in spaces I own, sorted by order
me.pieces("card", (c) => c.isSelected);
me.spaceOfKind("hand"); // first space I own with kind "hand"
me.ensureSpaceOfKind("hand"); // throws if missing
me.addSpace("scratch", new GridCell({ x: 0, y: 0 })); // adds + sets playerId
Per-player spaces use synthesized ids like ${playerId}/space/${configKey}, so player.space("hand") (matching spaceId) usually won't hit. Use player.spaceOfKind("hand") instead.
Space-scoped lookups
Space.pieces / piece / ensurePiece are scoped to that space:
space.pieces(); // pieces with spaceId === space.spaceId
space.pieces("card");
space.piece((p) => p.isSelected);
space.ensurePiece("p1");
space.addPiece("p2", new XToken({ order: 1 })); // adds + sets spaceId
Piece / Space / Player descent
piece.space; // Space | undefined
piece.ensureSpace(); // throws if no space
piece.ensureSpace("grid-cell"); // narrows the returned Space
piece.player; // Player | undefined (via piece.space.player)
space.player; // Player | undefined
space.ensurePlayer(); // throws if unowned
Gotchas
currentPlayeris "the player this hook is being run on behalf of," which is server-set. Don't override it.activePlayerIdsis the source of truth for whose turn it is. Set it inapplyActionsto advance turns.addPiece/addSpacethrow on duplicate ids — generate stable ids you can derive (e.g.${space.spaceId}/${turn}), not random UUIDs in render-time logic.piece.publicStateincludes the framework'sPieceDefBaseState(order,currentOrientationIndex,isSelected,spaceId). Don't shadow those keys in your ownPublicStategeneric.piece.privateStateisPrivateState | nullafter scrubbing — read-side hooks must handlenull(you'll see your own pieces normal, opponents' asnull).state.pieces()returns objects sorted byorder; if you mutateorder, subsequent reads in the sameapplyActionscall already see the new order.