This documents framework v1, which is deprecated. Games already on it keep running and their authors can keep editing them, but new games are created on the current framework — start with React frontends.
Actions
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 isdisabled) is rejected by the host beforeapplyActionsruns — don't re-check inside the hook. applyActionsMUST mutatestatein place. The return type isvoid.- Read-only hooks (
render,getSelectableItems,getPlayerScores,isGameOver) may readmetaData, but must not write it. OnlyapplyActionsandpreGameInitializationcan mutate it. A write from a read-only hook throwsTypeError, and while the server is validating a submitted action that refuses the action. metaDatais public — sensitive intermediate state belongs in a player'sprivateState(seestate-data).- Action handling is server-authoritative. The client emits a candidate action; the server runs
applyActionsand broadcasts the new state.