PlayMesh/Docs
HomeGitHubnpm

Instance State

Synchronized runtime key-value state scoped to each instance.

Overview

Every Instance has three key-value stores, all scoped to that instance. In single-node mode they live in memory; with Redis they are stored in Redis hashes and consistent across all nodes.
Method / PropertyDescriptionReturns
instance.stateServer-only runtime state. Never leaves the server.ScopedState
instance.publicStateSynced live to every client in the instance - a snapshot on join, then every change.ScopedState
instance.userState(userId)Private per-user state, synced only to that user's own clients. Other members never receive it.ScopedState
Instance state is for runtime data: active while the instance exists, discarded when it is destroyed. It is not a database replacement.

Synced state

All synced state is server-writable only - clients hold live read replicas via client.stateOf(path), client.userStateOf(path), and client.onStateChange(...).
// Server
await match.publicState.set('round', 3);           // everyone sees this
await match.userState('alice').set('hand', cards); // only Alice sees this
await match.state.set('spawn-seed', seed);         // nobody but the server

// Client
client.stateOf('ranked/match-42');      // public state replica
client.userStateOf('ranked/match-42');  // your private state
client.onStateChange(change => {
  // change.scope: 'public' | 'user' ('user' marks your private state)
  // change.revision: monotonic per-scope revision
});

API

All three stores share the same ScopedState API:
Method / PropertyDescriptionReturns
instance.state.get(key)Get a value. Returns undefined if not set.Promise<unknown>
instance.state.set(key, value, opts?)Set a value (JSON-serialized). On publicState, { visibleTo: userIds } restricts who receives it.Promise<void>
instance.state.patch(entries, opts?)Set many keys and delete those whose value is undefined - one atomic change, one revision, one sync message.Promise<void>
instance.state.delete(key)Remove a key.Promise<void>
instance.state.keys()List all keys in this namespace.Promise<string[]>
instance.state.entries()All key-value pairs in this namespace.Promise<Record<string, unknown>>
instance.state.clear()Remove all keys in this namespace.Promise<void>
instance.state.increment(key, delta, opts?)Atomically add delta (missing keys count as 0). { min, max } clamp bounds are applied atomically. Returns the new value.Promise<number>
instance.state.compareAndSet(key, expected, value)Atomically set only if the current value equals expected (canonical JSON comparison). Returns whether it changed.Promise<boolean>
instance.state.setIfAbsent(key, value)Atomically set only if the key is missing. Returns whether it was created.Promise<boolean>

Atomic operations

get() followed by set() loses updates under concurrent handlers and across nodes. The atomic operations run as single Lua scripts on Redis - truly atomic across the cluster, never a get-then-set (memory implementations are equivalent within one process):
const kills = await state.increment('kills', 1);                      // → new value
const health = await state.increment('health', -10, { min: 0, max: 100 });
const changed = await state.compareAndSet('phase', 'waiting', 'running'); // → boolean
const created = await state.setIfAbsent('owner', session.userId);        // → boolean
Successful mutations sync to clients like set(); failed conditional mutations emit no state event. For compareAndSet, expected: undefined matches only a missing key - a stored null matches only expected: null.

Batched writes (patch)

A round reset or a freshly generated floor can touch hundreds of keys. patch() writes them all as one atomic change: one revision, one sync message, one Redis call. Entries whose value is undefined are deleted. It works on every scope, including userState(id):
// 900 floor tiles in one message instead of 900
await arena.publicState.patch(
  Object.fromEntries(tiles.map(t => [`tile:${t.id}`, t.color]))
);

// Set and delete together
await arena.publicState.patch({ round: 2, 'tile:4': undefined });

// Private state too
await arena.userState('alice').patch({ coins: 5, gems: 2 });
Clients receive one change carrying patch (keys set, with their values) and deleted (keys removed) instead of key/value. The replica applies it in one step; client.stateOf() is always current. To wipe a whole scope, clear() is already a single call.

Visibility (visibleTo)

By default every public key reaches every member - including a cheater reading the network. visibleTo restricts a value to a list of user ids. Everyone else sees the key as deleted, in live changes, join snapshots and recovery snapshots alike, so their client never holds the value:
// Shooter: only players with line of sight get the enemy's position
await match.publicState.set(`pos:${enemy.userId}`, pos, { visibleTo: seenBy });

// Infinite world: block edits only for players near the chunk
await world.publicState.patch(chunkEdits, { visibleTo: nearbyUserIds });

// Make it public again
await match.publicState.set('pos:flag', flagPos);
Re-set a key to change who sees it - players who lose sight receive a deletion. Revisions stay gap-free for everyone, so filtering never triggers snapshot recovery. It works across nodes: visibility is stored with the value.
⚠
Key names and the fact that a key changed are not hidden - put secrets in values, not names. Restricted keys support get, set, patch and delete; atomic operations treat them as non-matching. Server-side get() and entries() always return the real values.
For events rather than state, send to a subset of members with instance.broadcastWhere().

Revisions and recovery

Every synced state change carries a monotonic revision - one sequence per instance for public state, one per user per instance for private state (Redis-backed counters stay monotonic across nodes). Client replicas apply changes strictly in revision order: duplicates and stale changes are ignored, and if a change is ever lost the client detects the gap and transparently fetches a fresh snapshot over the same connection - no reconnect. client.stateOf() / client.userStateOf() are always the authoritative view; during a recovery, individual onStateChange callbacks for the skipped changes may not fire, so re-read the replica instead of accumulating changes yourself.

Examples

// Boss health
await dungeon.state.set('boss-health', 5000);

dungeon.on('attack-boss', async (session, payload) => {
  const { damage } = payload as { damage: number };
  const health = await dungeon.state.increment('boss-health', -damage, { min: 0 });
  dungeon.broadcast('boss-health-updated', { health });

  if (health === 0) {
    dungeon.broadcast('boss-defeated', { killer: session.userId });
  }
});

// Match timer
match.onJoin(async () => {
  if (match.sessions.length === 2) {
    await match.state.set('started-at', Date.now());
    match.broadcast('match-started', { timeLimitMs: 5 * 60 * 1000 });
  }
});

// Kill counter - atomic, no lost updates under concurrency
dungeon.on('monster-killed', async () => {
  const kills = await dungeon.state.increment('kills', 1);
  dungeon.broadcast('kill-count', { kills });
});

Cleanup

domain.destroyInstance(id) automatically callsinstance.state.clear(). You can also clear manually.
// Automatic
await world.destroyInstance('dungeon-1');

// Manual reset
await dungeon.state.clear();

// Delete individual key
await dungeon.state.delete('boss-health');

Auto-destroy instances

Temporary instances (matches, dungeon runs) can clean themselves up once everyone leaves, while permanent instances never do:
world.createInstance('city');                              // permanent
world.createInstance('match-42', { autoDestroy: 60_000 }); // gone 60s after emptying
autoDestroy: true uses a 30-second grace period. The timer arms when the last member leaves (a never-visited instance is not reaped), cancels if anyone joins during the grace period, and checks cluster-wide emptiness through presence before destroying. instance.temporary tells the two kinds apart.

Use cases

  • Match timers and countdowns
  • Boss / objective health and progress
  • Runtime counters (kills, score)
  • Active objectives or flags
  • Game phase / round state
  • Per-instance configuration
⚠
Instance state is not persistent. With Redis, it survives node restarts but is cleared when the instance is destroyed. Use your own database for data that must outlast instances.