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.
Four buckets for game data: each lives in a different place and has different visibility rules.
class Card extends PieceDef<{ tapped: boolean }, { faceDown: boolean }, { cost: number }> {
readonly kind = "card";
readonly width = 100;
readonly height = 140;
readonly orientations = [{ imageSrc: "CardFront" }];
readonly static = { cost: 3 }; // class constant
protected publicDefaults() { return { tapped: false }; }
protected privateDefaults() { return { faceDown: true }; }
}
const card = new Card({ order: 1 });
card.publicState.tapped; // false, visible to all viewers
card.privateState.faceDown; // true on owner's view; null on others'
card.def.static.cost; // 3, same for every Card instance
| Bucket | Lives on | Mutable in | Sent on the wire? | Per-viewer scrubbing? |
|---|---|---|---|---|
static |
The Def class | never (declared at class-definition time) | no | n/a |
publicState |
The instance (Piece / Space / Player) |
applyActions, preGameInitialization |
yes, to every viewer | no |
privateState |
The instance | applyActions, preGameInitialization |
yes, scrubbed per viewer | yes (see below) |
metaData |
GameState |
applyActions, preGameInitialization |
yes, to every viewer | no |
static is read via piece.def.static.foo / space.def.static.foo / piece.static.foo. Use it for class-level constants (card cost, ability text) that an instance never overrides.
publicState and privateState are merged from publicDefaults() / privateDefaults() and the constructor's positional / named params (see pieces and spaces).
metaData is per-game ambient data. Type it via GameState<MetaData>:
type MyMeta = { phase: "draw" | "play" | "combat"; turn: number };
export const applyActions: ApplyActionsFn<MyMeta> = (state, action) => {
state.metaData = { ...state.metaData, phase: "combat" };
};
privateStateBefore each viewer receives a state update, the framework runs scrubStateForViewer:
Player.privateState → null for every viewer except that player.Space.privateState → null for every viewer except space.playerId's owner.Space with isPrivate: true has its privateState → null for every viewer except the space's owner.Read sites always see PrivateState | null. Handle the null branch:
const render: PieceRenderFn<{}, { value: number }> = (piece) => {
if (piece.privateState === null) {
return { type: "Image", src: "CardBack" }; // unauthorized viewer
}
return {
type: "Box",
children: [String(piece.privateState.value)],
};
};
piece.isPrivate / space.isPrivate / player.isPrivate returns true only when the bucket has been scrubbed (i.e. data.privateState === null).
The studio's Game Preview runs the same scrub before it hands state to a board, for every viewer its picker offers. Whichever seat or spectator you are viewing through, you see exactly what that viewer's client is sent in a real match, so a client that reads an opponent's privateState fails in the preview rather than waiting for production to find it. The preview's game state dialog shows the same per-viewer payloads, with a "Global" entry for the unscrubbed state the studio itself holds.
static.publicState.privateState + put the piece in an isPrivate space owned by the player. Or store it on the Player directly.metaData.A player's hand:
class Hand extends SpaceDef {
readonly kind = "hand";
readonly type = "Stack" as const;
readonly width = 600;
readonly height = 160;
readonly x = 0;
readonly y = 600;
readonly isPrivate = true; // pieces' privateState scrubbed for others
}
// In gameConfig:
player: { spaces: { hand: new Hand() } };
Per-player private bookkeeping (no piece needed):
gameConfig: () => ({
player: {
privateState: { secretGoal: null as string | null },
},
// ...
});
// In applyActions:
state.currentPlayer.privateState = {
...state.currentPlayer.privateState,
secretGoal: "domination",
};
privateState is null for unauthorized viewers, not undefined or an empty object. Branch on === null.isPrivate space has its privateState sent to everyone, isPrivate is the privacy switch, not the existence of a privateState field.Player.privateState = null directly hides it from everyone including the owner, usually a bug. Mutate the contents instead.static is on the Def, not the instance. piece.static is a shortcut for piece.def.static. Reassigning piece.static = ... doesn't work; it's a getter.metaData is public. Don't stash secrets there, use Player.privateState or piece privateState in an isPrivate space.undefined values you need preserved, no circular refs). Strings, numbers, booleans, plain arrays/objects, null.