Have more questions? Join our

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.

publicState, privateState, static, metaData

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

Where each lives

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" };
};

Viewer scrubbing — privateState

Before each viewer receives a state update, the framework runs scrubStateForViewer:

  • Player.privateStatenull for every viewer except that player.
  • Space.privateStatenull for every viewer except space.playerId's owner.
  • A piece sitting inside a Space with isPrivate: true has its privateStatenull 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).

Choosing the right bucket

  • The value is a class constant (same for every instance): static.
  • The value can change but every player should see it (board position, tap state, score visible to all): publicState.
  • The value is hidden from opponents (card identity in hand, sealed bid): privateState + put the piece in an isPrivate space owned by the player. Or store it on the Player directly.
  • The value is per-game ambient (current phase, last action timestamp, multi-step action staging): metaData.

Privacy patterns

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",
};

Gotchas

  • privateState is null for unauthorized viewers, not undefined or an empty object. Branch on === null.
  • A piece NOT inside an isPrivate space has its privateState sent to everyone — isPrivate is the privacy switch, not the existence of a privateState field.
  • Setting 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.
  • All four buckets must be JSON-serializable (no functions, no Dates, no undefined values you need preserved, no circular refs). Strings, numbers, booleans, plain arrays/objects, null.