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": { … }
}| Field | Set by | Notes |
|---|---|---|
type | both | see tables below |
roomId | client on join; server echoes | arbitrary string |
peerId | client | its own stable id (e.g. android_063ece02); ignored by the server on relay - the session is authoritative |
targetPeerId | client | present → unicast; absent → broadcast to the rest of the room |
senderPeerId | server | stamped on every relayed frame from the sender’s session |
peers | server | only on room-joined |
payload | both | type-specific, see below |
Client → server
type | payload | Effect |
|---|---|---|
join | { "name": string } | Join roomId as peerId. Server replies room-joined and broadcasts user-joined. |
offer | SdpPayload | Relayed to targetPeerId. |
answer | SdpPayload | Relayed to targetPeerId. |
candidate | IceCandidatePayload | Relayed (currently broadcast). |
chat | ChatMessagePayload | Broadcast to the rest of the room. Not stored. |
screen-share | ScreenSharePayload | Broadcast. |
video | VideoStatePayload | Broadcast. Carries camera state, mic-mute, and (on connect) avatar. |
leave | - | Leave the room. Also happens automatically on socket close. |
Server → client
type | Extra fields | Meaning |
|---|---|---|
room-joined | peers: [{peerId, name}] | You joined. peers is everyone already here (empty if you’re first). You become the offerer toward each. |
user-joined | peerId, payload: {name} | Someone joined after you. They will offer to you. |
offer / answer / candidate | senderPeerId, payload | Relayed SDP / ICE. |
chat | senderPeerId, payload | Relayed chat. |
screen-share | senderPeerId, payload | A peer started/stopped sharing (+ rotation). |
video | senderPeerId, payload | A peer toggled camera/mic (+ avatar on connect). |
user-left | peerId | A peer left or disconnected. |
room-full | roomId | (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 onvideobecause 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:…}