Actions and buttons
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.
That server call is charged to the same sandbox execution budget as the action it validates. An enumeration whose cost grows with the size of the board can exhaust that budget, and the player's action is then refused. Keep the work proportional to what the player can actually do, not to how much state exists.
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.
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.