Have more questions? Join our

Game logic hooks

Each hook is a top-level export from /src/game.ts. Only applyActions and preGameInitialization may mutate state — the framework deep-freezes gameState before every other hook, and a write throws TypeError.

Your React client runs these same functions locally to predict the result of a click, so they must be pure with respect to their inputs. Side effects (timers, randomness outside preGameInitialization) will desync the client from the server.

gameConfig (required)

type GameStateConfigFn = (params: { numPlayers: number }) => GameStateConfigObject;

Called once. Deterministic — same params, same output. See v2-game-source for the config object's fields.

applyActions (required)

type ApplyActionsFn<MetaData = Record<string, unknown>> =
  (gameState: GameState<MetaData>, action: Action) => void;

Called once per legal action. Mutates gameState in place and returns nothing.

export const applyActions: ApplyActionsFn = (state, action) => {
  if (action.type === "SpaceClick") {
    action.space.addPiece(`p${Date.now()}`, new XToken({ order: 1 }));
  } else if (action.type === "PieceClick") {
    action.piece.isSelected = !action.piece.isSelected;
  } else if (action.type === "ButtonClick") {
    if (action.button.id === "end-turn") passTurn(state);
  }
};

The action union

type Action =
  | { type: "SpaceClick"; space: Space }
  | { type: "PieceClick"; piece: Piece }
  | { type: "ButtonClick"; button: Button };

space / piece are the live instances from gameState — see v2-game-state. button is the full Button you returned from getButtons.

An action only reaches you if it was legal: it must have appeared in getAvailableActions for that viewer. You do not need to re-check membership, but you should still validate anything the enumeration can't express.

Multi-step turns

Stash intermediate per-turn state in gameState.metaData. It persists between actions and is visible to everyone.

getAvailableActions (required)

type GetAvailableActionsFn<MetaData = Record<string, unknown>> =
  (gameState: GameState<MetaData>) => AvailableAction[];

type AvailableAction = {
  action: RemoteAction;      // the fixed wire union, below
  intent: ActionIntent;
  label?: string;            // bot/log text, not UI copy
};

type ActionIntent = "choice" | "confirm" | "cancel" | "undo";

type RemoteAction =
  | { type: "SpaceClick"; spaceId: string }
  | { type: "PieceClick"; pieceId: string }
  | { type: "ButtonClick"; button: Button };

Enumerate every action the current viewer may legally take right now. This is the input gate: an action not in this list is refused with INVALID_ACTION, so an omission is a move the player cannot make.

export const getAvailableActions: GetAvailableActionsFn = (state) =>
  state
    .spaces("grid-cell", (s) => s.pieces().length === 0)
    .map<AvailableAction>((s) => ({
      action: { type: "SpaceClick", spaceId: s.spaceId },
      intent: "confirm",
      label: `Play cell ${s.spaceId}`,
    }));

Your client calls this through useAvailableActions(), and each entry's action is already in the shape match.click takes — there is nothing to translate.

Runs per viewer, on state whose privateState is already scrubbed for that viewer. Don't decide legality from private data you can't see.

Choosing an intent

intent says what the action does to the decision in progress. Pick it from the game's own structure, never from the control that happens to surface it — two identical-looking buttons can carry different intents.

intent means terminal for this decision?
choice advances the decision without ending it — select a piece, pick a target, stage a placement no
confirm commits the decision and hands play forward yes
cancel abandons the decision in progress, back to the state before its first choice
undo reverses an already-committed step

cancel and undo look the same in a UI and are opposites to anything searching the game: cancel never crosses a commit boundary and always lands on a state already visited; undo rewinds one that was committed. Keep them distinct.

A game with no staging step — tic-tac-toe, where placing a mark ends your turn — tags every action confirm and never uses the other three.

Buttons

If you also define getButtons, every button the player can press must appear here too, as a ButtonClick carrying that full Button:

...getButtons(state).map<AvailableAction>((button) => ({
  action: { type: "ButtonClick", button },
  intent: button.id === "end-turn" ? "confirm" : "choice",
})),

A button with disabled: true is refused even when enumerated, so mapping getButtons straight through stays correct.

Cost

This is not a per-render call. The server runs it once per applied action — exactly where the old getSelectableItems ran — and the client memoizes it on state identity, so it recomputes once per state change. Enumerating a large cross-product is affordable.

getSelectableItems (legacy)

type GetSelectableItemsFn<MetaData = Record<string, unknown>> =
  (gameState: GameState<MetaData>) => SelectableItem[];

type SelectableItem = { itemType: "Space" | "Piece"; itemId: string };

The v1 predecessor of getAvailableActions: a list of click targets rather than actions. Still supported, and a game defining it (and not getAvailableActions) behaves exactly as before — but don't reach for it in a new game.

Define one or the other, never both: when getAvailableActions is present it is the only gate consulted, and this hook is ignored.

A target list can say a piece is clickable; it cannot say whether clicking it selects, moves or commits. That gap is why getAvailableActions exists — a bot or a search reading targets alone cannot tell a move that advances the game from one that only changes what is highlighted, and gets stuck selecting and deselecting forever.

getPlayerScores (required)

type GetPlayerScoresFn<MetaData = Record<string, unknown>> =
  (gameState: GameState<MetaData>) => Scores;

type Scores = Record<string /* playerId */, {
  publicPoints: number[];   // one entry per scoreLabel
  privatePoints: number[];  // nulled on the wire for other viewers
}>;

Keys are player ids stringified — use String(playerId) or [playerId] computed syntax. publicPoints[i] lines up with gameConfig().scoreLabels[i].

isGameOver (required)

type IsGameOverFn<MetaData = Record<string, unknown>> =
  (gameState: GameState<MetaData>, scores: Scores) => boolean;

Called after getPlayerScores. Return true to end the game.

getButtons (optional)

type Button = {
  id: string;
  label: string;
  location: "PlayerHeader" | "GameHeader";
  disabled?: boolean;
};

type GetButtonsFn<MetaData = Record<string, unknown>> =
  (gameState: GameState<MetaData>) => Button[];

Your client renders these through useButtons() and sends match.click({ type: "Click", buttonId }). A disabled: true button is refused if clicked, so render it inert.

location is a hint your client is free to honor or ignore — in v2 you own the layout.

preGameInitialization (optional)

type PreGameInitializationFn<MetaData = Record<string, unknown>> =
  ((gameState: GameState<MetaData>) => void) | undefined;

Runs once, after gameConfig, before any action. May mutate. This is where randomized setup goes — shuffling a deck, dealing hands, choosing a start player — because gameConfig has to stay deterministic and this does not.

Gotchas

  • Everything except applyActions and preGameInitialization gets a frozen gameState. Writes throw TypeError.
  • Scores keys are strings, not numbers.
  • Don't depend on call order between the read-only hooks; they are independent reads of the same state.
  • state.currentPlayer is "the player this call is being made on behalf of" and is set by the server. Never assign it.
  • Because the client replays these locally, a hook that reads the clock or Math.random() outside preGameInitialization will predict one result and the server will produce another.