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.
state.removePiece("p123"); // → boolean (did it exist?)
state.removePieces((p) => p.kind === "token"); // → number removed
state.removeSpace("s123"); // → boolean
state.removeSpaces((s) => s.kind === "floor"); // → number removed
Removing an id that is already gone is not an error: it returns false / 0.
A space that still holds pieces throws, naming the space and the fix:
state.removeSpace("s123");
// Error: GameState.removeSpace: space "s123" still holds 2 pieces.
// Move them elsewhere first, or pass { withPieces: true } to remove them
// along with the space.
That's deliberate. A space you believed was empty but isn't is a logic bug, and the throw surfaces it while you're testing, instead of silently orphaning the pieces (they'd stay on the wire with piece.space undefined) or silently destroying them. When you do mean to take the contents too, say so:
state.removeSpace("s123", { withPieces: true });
state.removeSpaces((s) => s.kind === "floor", { withPieces: true });
The occupancy check runs across every matched space before anything is mutated, so a call that throws changes nothing. The same holds for the selector itself: every piece or space is tested before any is dropped, so a selector that throws partway leaves the board exactly as it was.
The selector on the bulk forms is required. pieces() and spaces() with no argument mean "all", so a no-argument removePieces() would read as "delete the board". Clearing everything has to be spelled out as removePieces(() => true).
Prefer these over splicing state.state.pieces / state.state.spaces yourself: the bulk forms compact in a single pass instead of an O(n²) splice-per-item loop, they avoid the mutate-while-iterating bug that silently skips adjacent matches, and they keep the internal piece index's invariants an implementation detail rather than something your game depends on.
One caveat when you re-use a derived id for a piece or space you removed earlier: the removed item's viewer-local clientState bucket is not cleared, so the new piece starts with whatever the old one left behind. Reset the fields you care about after re-adding, or include a generation counter in the id (${space.spaceId}/${floor}) so re-entering a level mints fresh ids.
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.