Signalling
Signalling is a service for adding multiplayer to browser games using WebRTC. It handles lobbies, matchmaking, and the initial handshake that lets two browsers connect directly. Primarily built for HTMLGameKit games.
WebRTC lets browsers send data directly to each other without a server in the middle, but they need help finding each other first. Before a direct connection can open, both sides must exchange small setup messages (called SDP offers/answers and ICE candidates) through a relay. That relay is called a signalling server, which is what this is.
A game client opens a WebSocket connection, gets placed into a room (by code or matchmaking), and once everyone is ready the server tells them to start exchanging WebRTC setup messages. After the direct connection is established the signalling server is no longer needed.
Some players sit behind strict firewalls or NAT that blocks direct connections. For those cases a TURN server can relay traffic so the connection still works. This service hands out time-limited TURN credentials so your players can connect even on restrictive networks. The built-in TURN relay is provided for free but has limited bandwidth. For high-traffic games consider running your own.
Concepts
- Game
-
Every game must be registered via
POST /gbefore use. Registration defines the roles players can fill (slots), when matchmaking should trigger (match mode), and how long relay credentials last. The returned game ID is used in all other endpoints. - Slot
- A named role within a game (e.g. "attacker", "defender"). Each slot has a minimum and maximum player count. The total room size is the sum of all slot maximums.
- Match Mode
-
Controls when matchmaking triggers.
eager(default) matches as soon as all slot minimums are met.greedywaits until all slot maximums are filled. - Room
- A short-lived lobby holding players. Created explicitly (private room with a shareable code) or automatically via matchmaking. Once all players ready up, the server triggers the WebRTC handshake.
- Preference
-
When joining the matchmaking queue, players send an ordered
list of slots they want to fill (e.g.
["attacker", "defender"]). The server assigns the first available match. An empty list means the player is happy to fill any open slot. Players in a room can also set a freeform preference string that is broadcast to other players. - Player Identity
-
Each WebSocket connection has a player ID. Pass
?player_id=Xon the WebSocket URL to resume a known identity, or omit it to let the server generate one. Player records persist in the database per game, along with their ranking. - Rating
- Players start with a rating of 1500, tracked using the Glicko-2 ranking algorithm. After a match, both players report the result. When both reports are in the server resolves the outcome, updates rankings, and notifies both players. Disagreements (e.g. both claim a win) resolve as a draw. Rankings influence matchmaking: the server prefers to match opponents of similar skill.
- Matchmaking Queue
- A per-game queue. Players join with optional slot preferences and are pulled into a room when enough are waiting to satisfy the slot configuration. The server prefers groups of similar skill, and when a GeoIP database is configured, geographically closer players.
- ICE Servers
- Configuration your game client passes to the browser so it knows how to establish a direct connection. Includes a STUN server (discovers a player's public address) and a TURN server (relays traffic when a direct connection is not possible). This server generates time-limited TURN credentials on demand. Requesting credentials requires room membership, and is limited to 10 requests per connection.
Endpoints
POST/g- Register a new game. Returns the game ID and credentials.
GET/ws/GAME_ID?player_id=ID- WebSocket upgrade. Main connection point. Returns 404 if the
game is not registered. Append
?player_id=Xto resume an existing player identity; omit to auto-generate. GET/queue-status/GAME_ID- Current matchmaking queue counts by preference.
Game Registration
curl -X POST signals.htmlgamekit.dev/g \
-H "Content-Type: application/json" \
-d '{
"name": "my-game",
"slots": [
{ "preference": "attacker", "min": 1, "max": 2 },
{ "preference": "defender", "min": 1, "max": 2 }
],
"match_mode": "eager",
"turn_ttl": 1800
}'
{
"id": "a1b2c3d4e5f6",
"name": "my-game",
"password": "auto-generated-pw",
"match_mode": "eager",
"player_count": 4,
"turn_ttl": 1800,
"slots": [
{ "preference": "attacker", "min": 1, "max": 2 },
{ "preference": "defender", "min": 1, "max": 2 }
]
}
- name
- Required. 1-255 characters.
- slots
- Required. Each slot has a
preference(non-empty string),minandmax(min ≤ max, max ≥ 1). Total player count (sum of maxes) must not exceed 255. - password
- Optional. Auto-generated 16-character string if omitted. Used for the admin panel. Returned in the response. Store it somewhere safe.
- match_mode
- Optional.
"eager"(default) or"greedy". - turn_ttl
- Optional. How long TURN relay credentials are valid, in seconds. Default 3600 (1 hour). Range: 1-86400.
Create a Game
Queue Status
curl signals.htmlgamekit.dev/queue-status/GAME_ID
{
"game_id": "a1b2c3d4e5f6",
"waiting": { "attacker": 3, "defender": 1 },
"total": 4
}
A lightweight polling endpoint for lobby UI. Show your players how many others are waiting before they open a WebSocket.
WebSocket Protocol
Connect to /ws/GAME_ID. The player
starts unattached. Send a message to create a room, join
one, or enter the matchmaking queue. All messages are JSON with a
type field.
Rooms
Create a Room
{ "type": "create_room" }
// Response
{ "type": "room_created", "code": "ABC123", "player_id": "your_id" }
Creates a private room. Share the code with friends so they can join. Room size is determined by the game's slot configuration (sum of slot maximums).
Join a Room
{ "type": "join_room", "code": "ABC123" }
// Response to joiner
{ "type": "room_joined", "code": "ABC123", "player_id": "your_id",
"players": [{ "id": "...", "preference": null }] }
// Broadcast to existing players
{ "type": "player_joined", "player_id": "new_id", "preference": null }
Set Preference
{ "type": "set_preference", "preference": "attacker" }
// Broadcast to other players
{ "type": "preference_changed", "player_id": "id", "preference": "attacker" }
Ready / Unready
{ "type": "ready" }
{ "type": "unready" }
// Broadcast
{ "type": "player_ready", "player_id": "id" }
{ "type": "player_unready", "player_id": "id" }
When all players in a room are ready, the server sends
start_signalling to everyone. This is the signal
for your game client to begin the WebRTC handshake.
{ "type": "start_signalling",
"players": [
{ "id": "...", "preference": "attacker" },
{ "id": "...", "preference": "defender" }
] }
Matchmaking
{ "type": "join_queue", "preferences": ["attacker", "defender"] }
{ "type": "leave_queue" }
// Queued
{ "type": "queue_joined", "position": 1, "player_id": "your_id" }
// Matched (sent to all matched players)
{ "type": "match_found", "code": "XYZ789",
"players": [
{ "id": "...", "preference": "attacker" },
{ "id": "...", "preference": "defender" }
] }
The preferences field is an ordered list of slots
the player is willing to fill. The first available slot wins.
Omit or send an empty array to fill any open slot.
In eager mode, matching triggers as soon as all
slot minimums are satisfied. In greedy mode, it
waits until all slot maximums are filled. The server also
prefers to group players of similar skill and, when a GeoIP
database is configured, geographically closer players.
ICE Servers
Once in a room, request the server configuration your game client needs to establish a direct connection. The response includes a STUN server (discovers a player's public address) and a TURN server (relays traffic when direct connections fail). TURN credentials are time-limited and scoped to the game. Limited to 10 requests per connection.
// Send (requires room membership)
{ "type": "request_ice_servers" }
// Response
{ "type": "ice_servers",
"ice_servers": [
{ "urls": "stun:stun.l.google.com:19302" },
{
"urls": "turn:turn.htmlgamekit.dev:5349",
"username": "1719849600:a1b2c3d4e5f6",
"credential": "base64-hmac-sha1"
}
] }
Pass the ice_servers array directly to
new RTCPeerConnection({ iceServers }).
WebRTC Signalling
After start_signalling, exchange SDP and ICE
messages with specific players by their ID. These are the
setup messages that let two browsers negotiate a direct
connection. See the
MDN WebRTC signalling guide
for how to use these with RTCPeerConnection.
// Send
{ "type": "sdp_offer", "target": "player_id", "sdp": "..." }
{ "type": "sdp_answer", "target": "player_id", "sdp": "..." }
{ "type": "ice_candidate", "target": "player_id", "candidate": "..." }
// Received (relayed from another player)
{ "type": "sdp_offer", "from": "player_id", "sdp": "..." }
{ "type": "sdp_answer", "from": "player_id", "sdp": "..." }
{ "type": "ice_candidate", "from": "player_id", "candidate": "..." }
Match Reporting
After a game ends, both players report who won. When both reports are in, the server resolves the outcome, updates player rankings, and notifies both sides. Rankings use the Glicko-2 algorithm.
// Send
{ "type": "report_result", "opponent": "player_id", "outcome": "win" }
Valid outcomes: win, loss,
draw. If both players disagree (e.g. both claim
a win), it resolves as a draw. Each player can only report
once per opponent per room.
// When both players have reported
{ "type": "result_confirmed", "opponent": "player_id", "outcome": "win" }
{ "type": "rating_updated", "rating": 1532.14, "deviation": 290.32 }
Both messages are sent to both players. The
outcome reflects the resolved result, which may
differ from what was reported if there was disagreement.
Errors
{ "type": "error", "message": "Room not found" }
Errors never close the connection. Possible messages:
- Room not found
- The room code doesn't exist.
- Room is full
- The room already has the maximum number of players.
- Already in a room
- You tried to create or join a room while already in one.
- Not in a room
- You sent a room action (ready, set_preference, request_ice_servers, etc.) without being in a room.
- Target player not found
- The player you tried to send SDP/ICE to is not in your room.
- Already in queue
- You tried to join the queue while already queued.
- Invalid outcome
- Outcome must be
win,loss, ordraw. - Cannot report result against yourself
- Self-explanatory.
- Opponent not in room
- The opponent ID doesn't match any player in your room.
- Already reported result for this opponent
- Duplicate report for the same opponent in the same room.
- Invalid message format
- Malformed JSON or unknown message type.
- ICE server request limit exceeded
- Too many
request_ice_serversmessages on this connection (max 10).
Disconnect
When a player disconnects they are removed from their room or
queue. Remaining room members receive
player_left. Empty rooms are cleaned up
automatically after 10 minutes.
{ "type": "player_left", "player_id": "id" }