Documentation

Game logic

v1 · Deprecated

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

Hidden information

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.privateState → null for every viewer except that player.
  • Space.privateState → null for every viewer except space.playerId's owner.
  • A piece sitting inside a 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.

The preview toolbar includes restart, player-view, and checkpoint controls. Available controls depend on the framework.
The preview toolbar includes restart, player-view, and checkpoint controls. Available controls depend on the framework. Example data from the local development UI. Select the image to enlarge.

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.

Need a hand with this?

Join our Discord