Have more questions? Join our

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)

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 kindstate.pieces("x-token") returns every piece whose kind is "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

  • currentPlayer is "the player this hook is being run on behalf of," which is server-set. Don't override it.
  • activePlayerIds is the source of truth for whose turn it is. Set it in applyActions to advance turns.
  • addPiece / addSpace throw on duplicate ids — generate stable ids you can derive (e.g. ${space.spaceId}/${turn}), not random UUIDs in render-time logic.
  • piece.publicState includes the framework's PieceDefBaseState (order, currentOrientationIndex, isSelected, spaceId). Don't shadow those keys in your own PublicState generic.
  • piece.privateState is PrivateState | null after scrubbing — read-side hooks must handle null (you'll see your own pieces normal, opponents' as null).
  • state.pieces() returns objects sorted by order; if you mutate order, subsequent reads in the same applyActions call already see the new order.