Game State and Player Data
Plot keeps two synchronized object stores for each game session:
Plot.gameStateis readable by everyone and writable only by the host. Use it for shared phase, progress, results, and deadlines.Plot.playerDatais 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:
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:
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:
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:
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:
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.Initialis the first snapshot for that subscription.SnapshotReason.Changefollows a normal accepted change. When present,info.changecontains the accepted change as represented through this view. It is absent when Player Data changed because a player joined or rejoined.SnapshotReason.Resyncis 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:
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:
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:
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.