Documentation

Game logic

v1 · Deprecated

Framework v1 is deprecated for new games. Existing games remain playable and editable.

Actions and buttons

applyActions receives a single Action per call. The union is discriminated on type:

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

Branch on action.type:

import { ApplyActionsFn } from "boardweaver";

export const applyActions: ApplyActionsFn = (state, action) => {
  if (action.type === "SpaceClick") {
    action.space.addPiece(action.space.spaceId, new XToken({ order: 1 }));
  } else if (action.type === "PieceClick") {
    action.piece.isSelected = !action.piece.isSelected;
  } else if (action.type === "ButtonClick") {
    if (action.button.id === "pass") advanceTurn(state);
  }
};

Action types

type Payload Fires when
"SpaceClick" { space: Space } The viewer clicks a space whose id is in getSelectableItems.
"PieceClick" { piece: Piece } The viewer clicks a piece whose id is in getSelectableItems.
"ButtonClick" { button: Button } The viewer clicks a button returned by getButtons.

Space / Piece here are the live Space / Piece instances from gameState : see game-state. Button is { id: string; label: string; location: "PlayerHeader" \| "GameHeader"; disabled?: boolean }.

Selectability

An action only fires if its target was selectable for the viewer. getSelectableItems returns:

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

Buttons are gated separately via button.disabled returned from getButtons. See game-logic.

Multi-step flows: metaData

Stash intermediate per-turn state in gameState.metaData. It survives between actions, lives on the wire (visible to everyone), and is yours to shape.

// Step 1: player clicks a piece to declare an attacker. Stash its id.
// Step 2: next SpaceClick targets the defender's space.
export const applyActions: ApplyActionsFn = (state, action) => {
  if (action.type === "PieceClick" && action.piece.kind === "unit") {
    state.metaData = { ...state.metaData, attackerId: action.piece.pieceId };
    return;
  }
  if (action.type === "SpaceClick" && state.metaData.attackerId) {
    resolveAttack(state, state.metaData.attackerId as string, action.space);
    state.metaData = { ...state.metaData, attackerId: undefined };
  }
};

export const getSelectableItems: GetSelectableItemsFn = (state) => {
  if (state.metaData.attackerId) {
    return state.spaces("enemy-territory").map((s) => ({
      itemType: "Space", itemId: s.spaceId,
    }));
  }
  return state.pieces("unit").map((p) => ({
    itemType: "Piece", itemId: p.pieceId,
  }));
};

gameState.metaData is typed via the optional MetaData generic on GameState<MetaData>. Hooks share it.

Gotchas

  • An action whose target is not in getSelectableItems (or whose button is disabled) is rejected by the host before applyActions runs : don't re-check inside the hook.
  • applyActions MUST mutate state in place. The return type is void.
  • Read-only hooks (render, getSelectableItems, getPlayerScores, isGameOver) may read metaData, but must not write it. Only applyActions and preGameInitialization can mutate it. A write from a read-only hook throws TypeError, and while the server is validating a submitted action that refuses the action.
  • metaData is public : sensitive intermediate state belongs in a player's privateState (see state-data).
  • Action handling is server-authoritative. The client emits a candidate action; the server runs applyActions and broadcasts the new state.

getSelectableItems (required)

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

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

export const getSelectableItems: GetSelectableItemsFn = (state) => {
  return state.spaces("grid-cell", (s) => s.pieces().length === 0)
    .map((s) => ({ itemType: "Space", itemId: s.spaceId }));
};

Called on every render. Returns what the current viewer may click. The host gates input : only ids in the returned list dispatch a SpaceClick / PieceClick. Read-only.

getButtons (optional)

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

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

export const getButtons: GetButtonsFn = (state) => [
  { id: "pass", label: "Pass", location: "PlayerHeader" },
  { id: "concede", label: "Concede", location: "GameHeader", disabled: state.activePlayerIds.length === 0 },
];

Called on every render. A ButtonClick action delivers the clicked Button to applyActions. disabled: true greys the button and blocks dispatch. Read-only.

Need a hand with this?

Join our Discord