GameState<MetaData> is the live view of the game passed to every hook. Query methods return Player / Piece / Space instances; mutation happens through addPiece / addSpace and property setters, and is only legal inside applyActions and preGameInitialization.
Your React client reaches the same API through useGameState(), so everything here reads identically on both sides.
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 = [nextPlayerId(state)];
};
| Property | Type | Mutable |
|---|---|---|
currentPlayer |
Player<MetaData> |
no — server-set, the viewer this call runs for |
activePlayerIds |
number[] |
yes — this is whose turn it is |
metaData |
MetaData |
yes — free-form game-wide scratch state |
scoreLabels |
string[] | undefined |
yes |
Every query takes a selector, and singular vs plural differ in an easy-to-miss way:
piece / space / player, ensurePiece / ensureSpace / ensurePlayer): a bare string matches by id.pieces / spaces / players): a bare string matches by kind. state.pieces("x-token") is every piece of that kind, not a piece with that id.A predicate (item) => unknown works anywhere a selector does. Kind-typed overloads take the kind first, then a selector over the narrowed type.
state.pieces(); // all, sorted by order
state.pieces("x-token"); // narrowed to XToken
state.pieces("x-token", (p) => !p.isSelected);
state.piece("p1"); // by id → Piece | undefined
state.ensurePiece("p1"); // throws if missing
state.spaces("grid-cell", (s) => s.pieces().length === 0);
state.space("0");
state.spaceOfKind("grid-cell"); // first of kind, or undefined
state.ensureSpaceOfKind("grid-cell"); // throws if missing
state.players();
state.player(0); // by playerId (number)
state.ensurePlayer(0);
pieces() results are always sorted ascending by piece.order. There are no kind overloads for players.
const piece = state.addPiece("p123", new XToken({ order: 1 }));
piece.spaceId = "0"; // attach to a space
const space = state.addSpace("s123", new GridCell({ x: 0, y: 0 }));
space.playerId = state.currentPlayer.playerId;
Both throw on a duplicate id or an unregistered kind. Generate ids you can derive (${space.spaceId}/${turn}), not random ones — a random id makes the client's local prediction disagree with the server's.
space.addPiece(id, def) and player.addSpace(id, def) are shorthands that also set spaceId / playerId.
Common property writes:
piece.spaceId = newSpace.spaceId; // move a piece
piece.order = 5;
piece.isSelected = true;
piece.publicState = { ...piece.publicState, tapped: true };
space.isHidden = true;
state.activePlayerIds = [nextPlayerId]; // pass the turn
state.metaData = { ...state.metaData, phase: "combat" };
Player has the same piece / pieces / space / spaces / *OfKind methods, scoped to spaces that player owns and the pieces inside them:
const me = state.currentPlayer;
me.pieces("card", (c) => c.isSelected);
me.ensureSpaceOfKind("hand");
Per-player spaces get synthesized ids like ${playerId}/space/${configKey}, so player.space("hand") (an id match) usually misses — use player.spaceOfKind("hand").
Space scopes the same way over its contents:
space.pieces("card");
space.ensurePiece("p1");
And you can walk upward:
piece.space; // Space | undefined
piece.ensureSpace(); // throws if unattached
piece.player; // Player | undefined
space.player; // Player | undefined
activePlayerIds is the only source of truth for turn order. Set it in applyActions.piece.privateState is PrivateState | null after scrubbing — handle null.piece.publicState carries order, currentOrientationIndex, isSelected, spaceId. Don't shadow them.order re-sorts immediately: later reads in the same applyActions call see the new order.game.piece(storedId) is always | undefined because a snapshot can remove the entity — check it.