Realtime protocol (v1)

The realtime protocol runs over a WebSocket at wss://<realtime-host>/v1/ws with sub-protocol skein.v1. Frames are UTF-8 JSON text. The authoritative definition is packages/protocol/src (zod schemas); the Unity client mirrors it in Runtime/Core/Protocol.

Connecting

  1. Get a ticket: POST /v1/sessions/{id}/ticket (authenticated) → { ticket, url, expiresIn: 60 }. Tickets are Ed25519-signed JWTs, valid for 60 seconds and single-use (the realtime server records the jti).
  2. Open the WebSocket offering skein.v1. Browsers must come from an allow-listed Origin.
  3. Send HELLO within 10 seconds. The server answers WELCOME (or ERROR + close).

Envelope

Every message, in both directions:

Field Type Notes
v 1 Protocol version. Other values → ERROR UNSUPPORTED_VERSION and close 4002.
type string Message type.
sid string Session id. Must match the ticket's session.
from string Sender user id. Ignored on input — the server sets it from the ticket on relays.
ts number Sender clock (ms). Relays carry server time.
eid string Event id, unique per sender (8–64 chars [A-Za-z0-9_-]). Used for replies (re) and de-duplication.
cseq number Client → server only: per-connection counter. Presence messages with a non-increasing cseq are dropped (out-of-order).
seq number Server → client only, on durable events: session-wide order, gap-free.
cid string Server → client: originating connection id on relays.
re string Server → client: the eid this message answers.
d object Payload.

Message classes

  • Ephemeral (never stored, never sequenced, not echoed to the sender): CAMERA_UPDATE, SELECTION_UPDATE, PRESENCE_UPDATE, OBJECT_TRANSFORM_PREVIEW.
  • Durable mutations (sequenced, logged, persisted, acknowledged, echoed to everyone incl. sender): OBJECT_TRANSFORM, OBJECT_PROPERTIES, OBJECT_PARENT, OBJECT_CREATE, OBJECT_DELETE; plus server-originated SNAPSHOT_RESTORED.
  • Control: HELLO, PING, LOCK_REQUEST, LOCK_RELEASE, LOCK_FORCE_RELEASE, RESYNC_REQUEST, SESSION_LEAVE.

Client → server

Type Payload Notes
HELLO { ticket, lastSeq?, resumeToken?, client: { kind, version, unityVersion?, platform? } } lastSeq enables delta replay; resumeToken (from WELCOME) reattaches the previous connection id and its locks within 15 s.
PING { t } Every 5 s. Renews lock leases. Answered with PONG { t, serverTime }.
PRESENCE_UPDATE { status?, activity?, scene? } Only fields present change.
CAMERA_UPDATE { p:[x,y,z], r:[x,y,z,w], ortho, fov, size, scene?, rest? } Adaptive rate, ≤15 Hz. rest marks the last sample of a movement.
SELECTION_UPDATE { ids: ObjectId[≤64], primaryName? }
LOCK_REQUEST { ids: ObjectId[1..32] } Answered with LOCK_RESULT { granted, denied:[{id,userId,name}] }.
LOCK_RELEASE { ids }
LOCK_FORCE_RELEASE { id } Owner/admin only; audited.
OBJECT_TRANSFORM_PREVIEW { id, t } Relayed only if the sender holds the lock.
OBJECT_TRANSFORM { id, scene?, t:{p,r,s}, prev?, label? } Local position / rotation / scale. Requires lock.
OBJECT_PROPERTIES { id, scene?, name?, active?, props?:[{c, path, type, value}], label? } c = "<Type.FullName>#<index>". Types: float int bool string enum color vec2 vec3 vec4 quat. Requires lock.
OBJECT_PARENT { id, scene?, parent: ObjectId | null, index, label? } Re-parent and/or reorder. Requires lock.
OBJECT_CREATE { id: "n:<uuid>", scene, name, parent, index, active, t, source } source: {kind:'empty'} | {kind:'primitive', primitive} | {kind:'prefab', guid}. Creator automatically receives the lock.
OBJECT_DELETE { id, scene?, label? } Requires lock; releases it.
RESYNC_REQUEST { fromSeq? } Replay events after fromSeq, or full state if omitted / not available.
SESSION_LEAVE {} Graceful leave: releases locks immediately. Closing the socket without it keeps the slot for 15 s.

Server → client

Type Payload
WELCOME { you, resumeToken, participants, locks, seq, sync: 'none'|'delta'|'full', heartbeatMs, lockLeaseMs, project, session, serverTime, baselines }
STATE_SYNC { seq, part, parts, objects: ObjectState[] } — chunks of 500 objects
SESSION_JOIN / SESSION_LEAVE { participant } / { cid, userId, reason: left|disconnected|timeout|revoked }
PRESENCE_UPDATE partial participant (status, activity, scene, connected, role)
CAMERA_UPDATE, SELECTION_UPDATE, OBJECT_TRANSFORM_PREVIEW relayed payloads
OBJECT_LOCK / OBJECT_UNLOCK { locks: LockInfo[] } / { ids, reason: released|expired|disconnected|forced }
ACK { seq, duplicate? } (with re)
REJECT { code, message, id?, state?, lockedBy? } (with re) — state is the authoritative object state to revert to
ERROR { code, message, detail?, fatal } — fatal errors are followed by a close
SNAPSHOT_CREATED / SNAPSHOT_RESTORED { snapshotId, name, userId, userName, scene } (restore is sequenced)

ObjectState = { id, scene?, name?, active?, parent?, index?, t?, props?: { "<c>|<path>": {type, value} }, created?, deleted?, ver, by? }.

Object ids

  • g:GlobalObjectId_V1-2-<sceneGuid>-<fileId>-<prefabInstanceId> — objects saved in a scene file.
  • n:<uuid> — created during the session; children of a created prefab instance: n:<uuid>-c<i>-<j>… (sibling-index path).

Ids must match ^[gn]:[A-Za-z0-9_\-]+$.

Recovery

  • Duplicates: mutations are de-duplicated by eid for 5 minutes — a retried mutation returns ACK {seq, duplicate:true} and is not re-applied.
  • Gaps / out of order: clients apply durable events strictly in seq order, buffer events that arrive early, and send RESYNC_REQUEST {fromSeq} for missing ranges.
  • Reconnect: HELLO {lastSeq, resumeToken} → sync: 'delta' replays missed events from a per-session log (last 10 000 events); if the gap is larger or the log was trimmed → sync: 'full'.
  • Resume: within 15 s of an unclean disconnect the same connection id is restored, so locks are kept. Pending mutations are resent with their original eids. Without a resume, the client re-requests locks first and reverts anything it can no longer lock.

Close codes

Code Meaning Client should
1000/1001 Normal / server going away Reconnect (1001)
4001 Authentication failed / signed out Ask the user to sign in
4002 Unsupported protocol version Ask the user to update
4003 Forbidden (removed from project, session full) Stop
4004 Session ended / project deleted Stop
4008 Rate limited repeatedly Reconnect with backoff
4009 Replaced by a newer connection Stop
4010 Invalid message (e.g. no HELLO) Fix the client
4011 Handshake timeout Reconnect

Limits

Frames ≤ 64 KB. Per-connection token buckets (rate/s, burst): total 120/240, camera 30/30, previews 40/40, mutations 60/200, presence 5/10, locks 20/60. Exceeding them drops presence silently and answers mutations/locks with REJECT RATE_LIMITED; sustained abuse closes with 4008.

Bandwidth

A camera frame is ~330 bytes. Per moving camera, each receiver gets at most 15 frames/s (≈5 KB/s); a still camera costs one keepalive every 10 s.