Realtime channels

Use Plot.realtime.channels for transient events and high-frequency values: effects, pointer movement, aim, and position. Use Plot.gameState for synchronized shared state and Plot.playerData for synchronized session data owned by one player.

Create, receive, and send

Choose a descriptive name and one payload type:

import Plot, { ChannelDelivery } from '@plot.game/sdk'

type Effect = {
  sceneId: string
  effect: 'bell'
}

const effects = Plot.realtime.channels.channel<Effect>('scene.effect', {
  delivery: ChannelDelivery.Reliable,
})

const unsubscribe = effects.subscribe(({ payload }) => {
  // Validate relevance against current synchronized game state.
  if (payload.sceneId !== Plot.gameState.get().sceneId) return
  if (payload.effect === 'bell') playBell()
})

await Plot.ready()

effects.broadcast({ sceneId: 'study', effect: 'bell' })
// Use effects.sendTo(playerId, effect) to target one player instead.

// Later, when this feature no longer receives effects:
unsubscribe()

This is a transient effect: it is not reconstructed for a late joiner, and a browser crash near delivery can replay it within the retained window. Keep important progress in synchronized state.

channel<T>() returns a typed Channel<T>. Its generic is an unchecked compile-time assertion for autocomplete; it does not validate your game-specific shape. If you omit the type, sends accept an unknown value and received payloads use Plot's JsonValue type. Every send is checked against Plot's supported JSON value model, and a receiver should still validate the fields, discriminants, IDs, and ranges it uses before applying the payload.

broadcast() sends to every player who can receive game messages, including the sender. sendTo() targets one stable PlayerId. A received ChannelMessage contains the trusted fromPlayerId and a deeply immutable payload. “Trusted” here means Plot supplies the authenticated sender's session-scoped ID; it does not make a player-authored score, answer, timestamp, or other gameplay claim authoritative. Plot games normally use those claims under the cooperative gameplay model.

subscribe() returns an idempotent Unsubscribe function. Register reliable listeners during startup, before yielding to unrelated asynchronous work.

Choose a delivery mode

ChannelOptions.delivery affects outgoing messages from that channel view. It does not filter subscriptions.

  • ChannelDelivery.Reliable is the default for transient game events and control messages. Plot preserves accepted per-recipient order and retains bounded in-memory history for five minutes through temporary interruptions. A surviving page suppresses replay attempts it has already handled. A browser crash after listener execution but before acknowledgement can still redeliver a message, so make duplicate-sensitive handlers idempotent with an action ID.
  • ChannelDelivery.Latest suits pointer movement, aim, position, and other continuously replaced samples. Plot may replace an older undelivered sample with a newer one for the same sender, channel, and route. Samples may be dropped and are not replayed by recovery. A reloaded game starts a fresh sample stream. Game code does not add transport sequence numbers or reconnect identifiers.

For replaceable position data, send the newest complete sample:

type Positions = Record<string, { x: number; y: number }>

const positions = Plot.realtime.channels.channel<Positions>(
  'player.positions',
  { delivery: ChannelDelivery.Latest },
)

positions.broadcast(currentPositions)

Broadcast and targeted sends are separate routes. If several independently changing values must coexist, include their current values in one payload or give each stream a distinct name.

Both broadcast() and sendTo() are synchronous, return void, and deliberately expose no delivery receipt. They validate arguments before returning. Until Plot installs the complete ready state, they throw PlotNotReadyError and do not start readiness. Sends are already available inside pre-registered initial snapshot callbacks, which run just before ready() resolves. After a valid send returns, Plot owns submission and recovery; game code does not await it or implement a retry loop. A return without an error is not evidence that another game copy already ran its listener. If a send detects that Plot cannot safely retain a reliable message, it throws a terminal internal-error synchronously. A hold deadline can instead expire after the send returned. Either failure stops this SDK instance; later valid party and state reads, state writes, and channel sends report the retained error. Reload or exit instead of retrying on the same page. Once this player's participation or the session has ended, a later valid send likewise throws the retained error instead of accepting new traffic.

Reliable sends invoked by one game copy retain invocation order. On receipt, Plot also waits until any synchronized-state commit the sender had already observed is locally installed before invoking the channel listener. Clock values do not define this order.

Reloads, disconnects, and new players

Plot retains admitted reliable messages for five minutes, subject to finite message and byte budgets. It applies backpressure before accepting a send that would require discarding an unexpired event. A returning participation receives one complete current world before retained events are replayed, even when the whole page was reloaded. Because a browser crash can happen after a listener runs but before receipt is recorded, make duplicate-sensitive actions idempotent when replay matters. A stable event ID can suppress repeated effects, but it does not make a later asynchronous write part of channel delivery. Keep player choices and shared results in synchronized state when they must survive refresh.

Messages expire five minutes after admission. Completed planned service updates save unexpired history, but an unexpected service crash may lose it. The Plot.connection.recovery diagnostics expose known missing ranges through replayGap before replaying what remains. Recovery reports connected only after all current state is installed. A checkpoint-restored recovery can also lose messages admitted after the last snapshot without a saved gap position, so it signals possible history loss even when replayGap is null. The snapshot is already current: replayed events must not apply a state effect for a second time. Latest messages are never replayed.

Checkpoint recovery discards outgoing messages queued against the previous world. Ordinary reconnects and planned updates preserve queued reliable work.

If Plot cannot submit retained reliable traffic before its five-minute local hold deadline, it ends this SDK runtime rather than silently dropping a message. That deadline can expire after the original send returned; later valid party and state reads, state writes, and channel sends then report the retained internal-error.

A player whose state is PartyPlayerState.Disconnected remains a valid sendTo() target. Plot retains reliable targeted traffic within the five-minute retention window; latest traffic may still be replaced or dropped. A player who has left is no longer a valid target.

A Joining player cannot receive game messages yet. Wait for their onPlayerChanged() transition to Connected before targeting them. You do not need a separate ready message or Player Data flag for this SDK-level check.

Register reliable listeners during startup, before ready(). A channel is not a mailbox that a feature can inspect later; if a message has no listener when Plot delivers it, the feature cannot read it afterward.

latest samples may be coalesced or dropped while disconnected and are never a reload-recovery mechanism. If later code or a later joiner needs a value, store the synchronized shared result in Plot.gameState, or in Plot.playerData when it genuinely belongs to one player.

A broadcast is not retroactive. A new player starts receiving broadcasts after their onPlayerChanged() transition to Connected. onPlayerAdded() happens earlier, when the stable party slot and initial Player Data appear in Joining state. Messages accepted after the transition are ordered behind that party change, so the receiver never handles one against an older party snapshot.

Channel identity and naming

The name passed to channel<T>() is the subscription identity. Selecting the same name and delivery mode again returns the same channel object. Selecting that name with a conflicting delivery throws TypeError instead of creating two mailboxes that could silently disagree.

Names must be strings from 1 through 128 characters whose JSON encoding fits 130 UTF-8 bytes. Names made from letters, digits, dots, underscores, and hyphens fit at the full 128-character length. Plot does not assign meaning to them. Prefer lowercase dotted names grouped by game concept:

  • game.phase
  • puzzle.guess
  • player.position
  • drawing.stroke
  • chapter.study.clock.answer

Payloads, immutability, and validation

A channel payload is a JsonValue: a JsonPrimitive (null, a boolean, a finite number, or a string), a dense ordinary JsonArray, or a plain JsonObject whose own properties are enumerable, string-keyed data properties. Received values use the deeply readonly ImmutableJson<T> shape.

undefined, Date, bigint, Map, Set, typed arrays, functions, symbols, class instances, sparse or decorated arrays, non-finite numbers, accessors, objects with symbol-keyed or non-enumerable properties, and cyclic values are rejected. Convert a date to an ISO string and a big integer to a decimal string before sending it.

Payload validation is bounded to 32 nested levels, 10,000 value nodes, and 262,144 combined key and string characters. Keep high-frequency payloads far smaller for mobile bandwidth, allocation, and battery use.

Latest payloads also have a negotiated UTF-8 JSON byte limit. Read Plot.realtime.latest.get().maxPayloadBytes after readiness, or subscribe to Plot.realtime.latest before readiness to receive its initial status and changes. The current production limit is 512 bytes. An oversized latest send throws TypeError before submission. When available is false, latest samples are dropped; synchronized state and reliable messages can remain connected. Use this status to pause cosmetic high-frequency sending without pausing the whole game.

Reliable listener completion acknowledges delivery, including a listener that throws. It does not wait for an asynchronous game-state write. For a choice whose effect must survive a refresh, represent it in synchronized state.

Transport freshness is not the same as gameplay relevance. A pointer sample can still be well-formed and current for its sender but belong to a round that your game has already finished. Include a round, scene, puzzle, or action identity when game rules need that check, and reject payloads that do not match current synchronized state. Plot handles delivery order and ignores messages from a superseded game page.

channel<T>() throws TypeError for an invalid name, options object, or delivery value. broadcast() throws TypeError for an invalid payload. sendTo() also throws for an invalid player ID, an unknown or still-joining player, or a player who has left. subscribe() throws when its listener is not a function. A valid send before the complete ready state is installed throws PlotNotReadyError. Once this player's participation or the session has ended—or the SDK has entered a terminal state after a fatal failure—a later valid send throws the retained PlotOperationError. A reliable send also throws that error if it detects that Plot cannot safely retain the message.

For exact types, see RealtimeApi, ChannelsApi, ChannelOptions, Channel, ChannelMessage, and the ChannelDelivery type.