Most WebRTC signalling protocols invent a new wire format for what is structurally a message-passing problem. The SDP offer, the ICE candidate, the accept, the answer, the hangup; they are all payloads with a type and a destination. Symple treats them that way: presence, peer-to-peer messaging, and the full WebRTC call lifecycle run as typed JSON messages over WebSocket.
Two implementations exist: the Node.js server and browser clients documented here, and a native C++ server and client inside icey’s symple module. Same wire format, same call lifecycle, different runtimes. A C++ Symple server can signal a browser running symple-player, or vice versa; the protocol does not care what is on either end.
Protocol design
Every message is JSON with a consistent structure: type, from, to, id, data. Types are message, presence, command, event, with an optional subtype for specialisation (call:init, call:offer, etc.).
Peers are addressed as user|id. The user field alone targets all sockets for that user (multi-device); appending the socket id targets one specific connection. One addressing scheme covers broadcast, room-cast, user-cast, and device-specific routing.
CLIENT SERVER CLIENT
| | |
+-- connect(auth) -----------> | validate token (Redis) |
|<-- presence probe ----------> | --> broadcast -------------> |
|<-- presence response <------- | <-- broadcast <------------- |
| | |
| MESSAGING | |
|-- message(to: room) -------> | --> room broadcast -------> |
|-- message(to: user|id) ----> | --> direct route ----------> |
| | |
| SIGNALLING (via messages) | |
|-- call:init ----------------> | --> route -----------------> |
|<- call:accept <-------------- | <-- route <----------------- |
|-- call:offer (SDP) ---------> | --> route -----------------> |
|<- call:answer (SDP) <-------- | <-- route <----------------- |
|<- call:candidate (ICE) <----> | <--> route <-->-----------> |
| | |
|<=========== WebRTC P2P (media bypasses server) ============> |
| | |
| HORIZONTAL SCALING | |
| Redis pub/sub |
| +----+----+ |
| Server 1 Server 2 |
| | |
| Ruby client (direct |
| Redis inject, no WS) |
Presence
Presence is just messages with type: 'presence'. Connect broadcasts online: true, disconnect broadcasts online: false. Same routing, same format as everything else on the wire.
New clients send a presence probe on connect. Remote peers respond with their current state (without the probe flag, preventing loops), bootstrapping the roster without server-side state. The client-side Roster tracks peers in memory and fires events on changes (addPeer, removePeer).
WebRTC signalling
The full call lifecycle runs as message subtypes layered on regular messaging:
| Subtype | Direction | Purpose |
|---|---|---|
call:init | Caller to Callee | Initiate |
call:accept | Callee to Caller | Accept |
call:offer | Caller to Callee | SDP offer |
call:answer | Callee to Caller | SDP answer |
call:candidate | Both | Trickle ICE |
call:hangup | Either | End call |
The CallManager wires this together. It maps Symple message events to WebRTCPlayer methods and player events back to Symple messages. WebRTCPlayer does not know about Symple; CallManager does not know about ICE internals. The signalling transport is pluggable.
ICE candidates arriving before the remote description is set get buffered and flushed when it lands. This is the race condition most WebRTC implementations get wrong.
icey’s C++ implementation follows the same call lifecycle. The native symple module handles auth, presence, rooms, and the full call:* subtype sequence over WebSocket, feeding directly into icey’s WebRTC peer sessions and PacketStream pipeline. That is how camera-to-browser in 150 lines works: Symple carries the signalling, icey carries the media, and the protocol between them does not need translating.
Horizontal scaling
Multiple server instances behind a load balancer, connected via Redis pub/sub. A message from a client on Server A to a user on Server B publishes to Redis; the adapter on B picks it up and delivers. Socket IDs are globally unique across instances, so user|id addressing works the same regardless of which server handles either end.
Session data lives in Redis at symple:session:<token> with configurable TTL. Token auth works identically regardless of which server the client connects to.
The Ruby client
Production Rails apps need server-side push: background jobs, model callbacks, event triggers. The Ruby client skips the WebSocket entirely. It encodes Symple-compatible packets and publishes them directly to Redis; a Rails model callback fires, the adapter on each connected server picks the message up, and it appears on the client. The emit: true flag tells the adapter to rebroadcast across all server instances.
It also handles session lifecycle through ActiveRecord hooks: tokens created on login, TTLs extended on activity, sessions cleaned up on logout.
Client packages
Split into two with a clear boundary:
symple-client is pure messaging: connection management, routing, presence roster, rooms, event bus. 9KB. No media dependencies.
symple-player layers media engines on top: WebRTCPlayer (two-way video/audio), MJPEGPlayer, WebcamPlayer, and CallManager. Engines register with a preference score and capability check; the app queries Media.preferredEngine() to pick the best option for the platform and falls back gracefully.
icey’s symple module is the native C++ implementation: full protocol over WebSocket with auth, presence, rooms, and call signalling, running on libuv alongside the rest of the icey stack. icey-cli is the reference application: one C++ binary bundling a Symple server, TURN relay, WebRTC media pipeline, and web UI. Browser clients connect via symple-client and symple-player; the same Symple message channel carries application-level events like vision detections and speech activity alongside call negotiation.
Routing
Three patterns cover every case:
- No
tofield broadcasts to all joined rooms. toas a string ("user|id") routes to a specific peer or user.toas an array (["room1", "room2"]) multicasts to named rooms.
Dynamic rooms let clients join and leave on the fly. The server handles room mechanics; the Redis adapter ensures it works across server instances.
Getting started
npm install symple-server
node server.js
import { SympleClient } from 'symple-client'
const client = new SympleClient({
url: 'http://localhost:4500',
token: 'your-auth-token',
peer: { name: 'My App', group: 'public' }
})
client.on('connect', () => console.log('Connected'))
client.on('message', (m) => console.log('Message:', m))
client.on('presence', (p) => console.log('Presence:', p))
Same envelope for presence, chat, and the call lifecycle. The protocol does not change shape between them.
