Skip to Content
ReferenceSignaling protocol

Signaling protocol

JSON text frames over a single WebSocket, connected to the server root (wss://host/). Wire-compatible with the original Node.js server - the Rust rewrite changed nothing observable.

Message envelope

{ "type": "…", "roomId": "…", "peerId": "…", "targetPeerId": "…", "senderPeerId": "…", "peers": [ { "peerId": "…", "name": "…" } ], "payload": { } }
FieldSet byNotes
typebothsee tables below
roomIdclient on join; server echoesarbitrary string
peerIdclientits own stable id (e.g. android_063ece02); ignored by the server on relay - the session is authoritative
targetPeerIdclientpresent → unicast; absent → broadcast to the rest of the room
senderPeerIdserverstamped on every relayed frame from the sender’s session
peersserveronly on room-joined
payloadbothtype-specific, see below

Client → server

typepayloadEffect
join{ "name": string }Join roomId as peerId. Server replies room-joined and broadcasts user-joined.
offerSdpPayloadRelayed to targetPeerId.
answerSdpPayloadRelayed to targetPeerId.
candidateIceCandidatePayloadRelayed (currently broadcast).
chatChatMessagePayloadBroadcast to the rest of the room. Not stored.
screen-shareScreenSharePayloadBroadcast.
videoVideoStatePayloadBroadcast. Carries camera state, mic-mute, and (on connect) avatar.
leave-Leave the room. Also happens automatically on socket close.

Server → client

typeExtra fieldsMeaning
room-joinedpeers: [{peerId, name}]You joined. peers is everyone already here (empty if you’re first). You become the offerer toward each.
user-joinedpeerId, payload: {name}Someone joined after you. They will offer to you.
offer / answer / candidatesenderPeerId, payloadRelayed SDP / ICE.
chatsenderPeerId, payloadRelayed chat.
screen-sharesenderPeerId, payloadA peer started/stopped sharing (+ rotation).
videosenderPeerId, payloadA peer toggled camera/mic (+ avatar on connect).
user-leftpeerIdA peer left or disconnected.
room-fullroomId(planned) Room at MAX_PEERS. Join refused.

Payload types

SdpPayload

{ "type": "offer" | "answer", "sdp": "v=0\r\no=- …" }

IceCandidatePayload

{ "sdpMid": "0", "sdpMLineIndex": 0, "candidate": "candidate:… udp …" }

ChatMessagePayload

{ "sender": "Alice", "message": "hi", "timestamp": 1725400000000 }

ScreenSharePayload

{ "active": true, "rotation": 0 }

rotation is Surface.ROTATION_0/90/180/270. null → the peer doesn’t report it (the web client); the viewer infers portrait/landscape from the frame instead.

VideoStatePayload

{ "enabled": true, "micEnabled": false, "avatarType": "PRESET", "avatarPreset": 3, "avatarPhoto": "<base64 JPEG>" }
  • enabled - camera on/off. Sent on every toggle.
  • micEnabled - mic mute state. Rides on video because it’s the only media-state type the relay whitelists. null = not reported.
  • avatarType / avatarPreset / avatarPhoto - the avatar to show when this peer’s camera is off. Only attached on the connect announce, not on every toggle. Absent = unchanged.

Full 1:1 handshake

See call lifecycle for the sequence diagram.

A → join {roomId:R, peerId:A, payload:{name:"A"}} A ← room-joined {peers:[]} B → join {roomId:R, peerId:B, payload:{name:"B"}} B ← room-joined {peers:[{peerId:A,name:"A"}]} A ← user-joined {peerId:B, payload:{name:"B"}} B → offer {targetPeerId:A, payload:{sdp}} A ← offer {senderPeerId:B, payload:{sdp}} A → answer {targetPeerId:B, payload:{sdp}} B ← answer {senderPeerId:A…, payload:{sdp}} A ↔ candidate … (both directions, via server) ── DTLS-SRTP media flows directly A ↔ B ── A → video {enabled:true, micEnabled:true, avatarType:…}