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
- 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 thejti). - Open the WebSocket offering
skein.v1. Browsers must come from an allow-listedOrigin. - Send
HELLOwithin 10 seconds. The server answersWELCOME(orERROR+ 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-originatedSNAPSHOT_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
eidfor 5 minutes — a retried mutation returnsACK {seq, duplicate:true}and is not re-applied. - Gaps / out of order: clients apply durable events strictly in
seqorder, buffer events that arrive early, and sendRESYNC_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.