Documentation

Game logic

v2 · Stable

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.

Need a hand with this?

Join our Discord