Have more questions? Join our

GameState API

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)];
};

Top-level

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

Lookups

Every query takes a selector, and singular vs plural differ in an easy-to-miss way:

  • Singular (piece / space / player, ensurePiece / ensureSpace / ensurePlayer): a bare string matches by id.
  • Plural (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.

Mutation

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" };

Scoped lookups

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

Gotchas

  • Plural string = kind, singular string = id. The single most common mistake.
  • 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.
  • Mutating order re-sorts immediately: later reads in the same applyActions call see the new order.
  • Stored ids can go stale. In your client, game.piece(storedId) is always | undefined because a snapshot can remove the entity — check it.