Game State and Player Data

Plot keeps two synchronized object stores for each game session:

  • Plot.gameState is readable by everyone and writable only by the host. Use it for shared phase, progress, results, and deadlines.
  • Plot.playerData is readable by everyone, while each player writes only their own object. Use it for a player's role, score, hand, or ready state.

Start with Game State. Add Player Data only when a fact belongs to one player. Use realtime channels for button presses, effects, movement, and other transient or frequently changing values; each synchronized write replaces and distributes a complete object.

Both stores are scoped to one game session. While that session remains active, startup and recovery install their current synchronized values, and late joiners receive those values before Plot.ready() resolves. Neither store is permanent account or saved-progress storage. Accepted writes are authoritative immediately and saved periodically for crash recovery. Planned service updates fully save accepted state first; unexpected crashes can restore an older snapshot. See Lifecycle and recovery for the exact boundary.

Store shared Game State

Define your shape, create a typed view, and subscribe before readiness:

import Plot from '@plot.game/sdk'

type GameState = {
  phase?: 'lobby' | 'playing' | 'complete'
  solvedPuzzleIds?: string[]
  endsAtGameTimeMs?: number
}

const gameState = Plot.gameState.as<GameState>()

gameState.subscribe((state) => {
  renderGame(state)
})

await Plot.ready()

Game State begins as {}. Use optional fields and defaults, or have the host initialize it before relying on required fields. The subscription receives its first complete snapshot before ready() resolves and runs again after changes or recovery. Reads are synchronous and return complete immutable snapshots.

Only the host can write Game State:

const party = Plot.party.get()

if (party.self.playerId === party.host.playerId) {
  await gameState.set({
    phase: 'playing',
    solvedPuzzleIds: [],
    endsAtGameTimeMs: Plot.time.now() + 30_000,
  })
}

A non-host write rejects with a not-host PlotOperationError. The host is always player 0; that role does not transfer during a session. Game State is a separate object, not an alias for the host's Player Data.

Store data owned by each player

Create another shape only for player-owned facts:

type PlayerData = {
  score?: number
  roundReady?: boolean
  clues?: string[]
}

const playerData = Plot.playerData.as<PlayerData>()

await Plot.ready()

console.log(playerData.self)
console.log(playerData.host)
console.log(playerData.get(3))

await playerData.updateSelf((current) => ({
  ...current,
  score: (current.score ?? 0) + 1,
}))

Every known player starts with {}. self is the current player's object, host is player 0's object, and get(playerId) returns another player's object or undefined for an unknown valid ID. Each player can call setSelf() or updateSelf() only for their own object.

roundReady is an example game-specific lobby choice. It is separate from the one-time SDK readiness provided by Plot.ready().

Every game copy can read all Player Data. Hiding a role or clue in the UI does not make it secret from modified browser code.

Choose set or update

gameState.set(value) and playerData.setSelf(value) are unconditional, last-write-wins replacements. Plot captures the supplied complete value once; it does not recompute it against a newer snapshot.

Use gameState.update(updater) or playerData.updateSelf(updater) when the new value depends on the current one:

await gameState.update((current) => ({
  ...current,
  solvedPuzzleIds: [
    ...(current.solvedPuzzleIds ?? []),
    'study-clock',
  ],
}))

Keep an updater pure. After a definite revision conflict, Plot may call it again with the newly installed value and retry up to three times. Do not play audio, change UI, send messages, or mutate captured variables inside it. Plot never reruns an updater after an ambiguous timeout.

All four write methods return Promise<void>. When one resolves, its accepted value is already available to synchronous reads. One game page queues writes across both stores in call order, but separate writes are not a transaction. At most 32 writes may be pending across both stores; await or combine writes instead of starting an unbounded group. Await a write before making a decision that depends on it.

A valid write made before Plot installs the complete ready state rejects with PlotNotReadyError. It does not start readiness. Writes are already available inside a pre-registered initial snapshot callback, which runs just before ready() resolves.

A structurally identical replacement succeeds without advancing the revision or emitting a change event. A value rejected by local validation produces TypeError; a failure reported by Plot rejects with PlotError. An operation-timeout has an unknown outcome, so reconcile with a later snapshot before repeating a non-idempotent action. See Error handling. If an older outcome is no longer available, operation-superseded rejects that write without rerunning its updater. Reconcile the installed world and any game-owned action receipt instead of assuming success or failure.

Subscribe to complete snapshots

For a complete startup or recovery rebuild, read these getters from the connected Plot.onLifecycleChange() callback. Party and all state are already installed, including values read through typed or schema-validated views. Use focused onChanged() listeners for live updates. The per-domain subscriptions below can handle initial, change, and recovery snapshots for a single surface instead.

Use subscribe() for rendering and logic that must rebuild from current state after startup or recovery:

import { SnapshotReason } from '@plot.game/sdk'

gameState.subscribe((state, info) => {
  if (info.affects('phase')) renderPhase(state.phase)

  if (info.reason === SnapshotReason.Resync) {
    clearTransientAnimations()
  }
})

playerData.subscribe((snapshot, info) => {
  for (const player of Plot.party.get().players) {
    if (info.affects(player.playerId, 'score')) {
      renderScore(
        player.playerId,
        snapshot.objects[player.playerId]?.score ?? 0,
      )
    }
  }
})

A subscription registered before readiness receives its first snapshot as Plot finishes startup, just before ready() resolves. One registered later receives the current snapshot immediately. Both return an idempotent unsubscribe function.

SnapshotReason explains why the snapshot arrived:

  • SnapshotReason.Initial is the first snapshot for that subscription.
  • SnapshotReason.Change follows a normal accepted change. When present, info.change contains the accepted change as represented through this view. It is absent when Player Data changed because a player joined or rejoined.
  • SnapshotReason.Resync is one complete replacement after recovery. Plot does not invent the incremental events that may have been missed.

For SnapshotReason.Initial and SnapshotReason.Resync, Game State's info.affects(...path) matches every path. Player Data's info.affects(playerId, ...path) matches every path for every player in that snapshot. For SnapshotReason.Change, it matches only the changed object or a player object installed by that party update.

React to continuous changes

Use onChanged() for effects that should run once as each accepted change happens:

gameState.onChanged((event) => {
  if (event.affects('phase')) showPhaseEffect(event.current.phase)
})

playerData.onChanged((event) => {
  if (event.affects('score')) {
    animateScore(event.playerId, event.previous.score, event.current.score)
  }
})

Both event types include the complete immutable previous and current objects, the record's revision, a local-write or remote-update reason, changedPaths, and affects(). Player Data events also include playerId.

Plot installs a replacement before invoking any listener. A synchronous getter inside a callback already returns the event's current value. Accepted Game State and Player Data changes share one session order, so continuously connected clients observe changes across both stores in the same order.

Unlike snapshot subscriptions, onChanged() does not run for initial state and does not fabricate events after a resync. Use it for incremental effects, not as the only source for stateful UI.

Target the part that changed

Prefer event.affects(...path) or snapshot info.affects(...) to inspecting paths yourself:

gameState.onChanged((event) => {
  if (event.affects('phase')) {
    renderPhase(event.current.phase)
  }
})

A path contains property names and array indexes, such as ['round'] or ['puzzles', 2, 'solved']; [] means the whole object. affects() returns true when the requested path, an ancestor, or a descendant changed. A false result guarantees that requested subtree did not change in this event.

Each accepted replacement exposes one non-empty changedPaths list. It is deterministic, immutable, unique, non-overlapping, and complete: every actual difference is covered, and every listed path identifies a subtree that differs. Broad edits may collapse to an ancestor or [] to keep the list at 64 entries or fewer. Treat the paths as invalidation targets, not patches; current is the state source.

Use compile-time types or a runtime schema

gameState.as<T>() and playerData.as<T>() are unchecked TypeScript views. They improve autocomplete but do not prove that stored data matches T. Plot still validates its supported value model and returns immutable snapshots.

For runtime validation or defaults, use withSchema() with any object that has a parse(value: unknown) method:

const gameState = Plot.gameState.withSchema({
  parse(value: unknown): GameState {
    return parseGameState(value)
  },
})

Keep the parser deterministic and free of side effects. It runs for reads, writes, updater results, snapshots, and change events on that view. Plot revalidates and freezes the parsed result. A schema affects only the returned view; it does not configure another handle or persist defaults produced during a read. Game State and every new or rejoining player's data begin as {}, so the parser must accept that object or supply defaults. Reads and writes surface parse errors unchanged. If parsing a subscription notification fails, Plot reports the original error to the browser and continues notifying unrelated listeners.

Because a schema can normalize or omit fields, its view recomputes change paths from the parsed previous and current values. It suppresses an ordinary change callback when both parse to the same visible value; initial and recovery snapshots are still delivered.

Supported values and limits

The root must be a plain object whose own properties are enumerable, string-keyed data properties. Nested values may be null, booleans, finite numbers, strings, similarly plain objects, dense arrays, valid Date values, or bigint. Arrays and objects are recursively frozen. Dates remain real Date instances, but their mutation methods throw.

undefined, maps, sets, URLs, regular expressions, errors, typed arrays, symbols, functions, accessors, class instances, sparse or decorated arrays, objects with symbol-keyed or non-enumerable properties, invalid dates, non-finite numbers, and cycles are rejected. Convert them to explicit plain-data representations such as strings, ordinary arrays, or { name, message } objects.

Validation allows at most 32 nested levels, 10,000 value nodes, and 262,144 combined key and string characters. Each encoded Game State or Player Data object may be at most 64 KiB. One complete session snapshot may contain at most 1 MiB across Game State and all Player Data objects.

Recovery, joins, and session lifetime

During a temporary disconnection, synchronous getters keep returning the latest cached snapshots, which may be stale. Plot restores synchronization automatically and may deliver one complete Resync snapshot. The original ready() remains complete in a surviving page; game code does not reconnect, replay writes, or call ready() again.

A first-time player receives current Game State and every player's data before their ready() call resolves. Existing players first observe the new stable slot as Joining together with its initial {} Player Data. Plot changes that same player to Connected and enables gameplay only after the new copy has installed a complete synchronized view. A non-host who leaves and rejoins keeps the same player ID: they move from Left through Joining, and only their Player Data and transient message state reset. Game State and every other player's data remain unchanged.

Reloading the game creates a new page, so startup code must register listeners and call Plot.ready() again. If the same participation is restored, Plot supplies current synchronized values and can replay reliable messages not yet recorded as received within their five-minute window. This does not reset the player's data. Plot never restores ordinary variables or UI state owned only by the old game page.

The host must also start its gameplay coordinator again after Plot.ready(). Derive the current phase and every pending transition from Game State and Player Data so the host can safely continue after a refresh or temporary disconnect. Plot keeps only one game copy active for each player. Store durable decisions in synchronized state so a refreshed host can resume them.

A platform-level game restart or reset ends and clears the old session, reloads the iframe, and initializes a fresh page. Do not confuse that lifecycle with an in-session SnapshotReason.Resync: a resync atomically replaces the current session's cached snapshots after a recoverable gap and does not reload the iframe.

A temporary host disconnect does not end the synchronized objects. Closing the host's party, having the host leave, or ending the game makes them terminal and Plot clears the session state. Cached snapshots may remain readable in the old page for a final screen, but they stop updating, writes reject, and a later game never receives them. Do not use either store as permanent account or saved-progress storage.

Data visibility and trust

Every game copy can read Game State and every player's data. Never store credentials, personal information, purchases, security-sensitive hidden answers, or platform authorization evidence in either store.

Host-only Game State writes and player-owned Player Data writes prevent accidental multi-writer conflicts; they are not an anti-cheat system. Plot uses a cooperative gameplay model, so a player may submit their own score, action, answer, or gameplay timestamp, and synchronized clues or solutions may be visible to modified client code. Games can rely on that model for their own social rules.

The platform still owns trusted identity, purchases, launch authorization, and session permissions. Never derive those decisions from game-written data. For software robustness, also validate game-specific fields, discriminants, IDs, and numeric ranges before using synchronized data. This protects the session from stale game versions, programming mistakes, and malformed values even when players are assumed not to cheat. A withSchema() view can centralize that runtime validation.