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);
}
};
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.
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.
intentintent 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.
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.
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.
applyActions and preGameInitialization gets a frozen gameState. Writes throw TypeError.Scores keys are strings, not numbers.state.currentPlayer is "the player this call is being made on behalf of" and is set by the server. Never assign it.Math.random() outside preGameInitialization will predict one result and the server will produce another.