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 /g before 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. greedy waits 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=X on 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=X to 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), min and max (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

Slots

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, or draw.
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_servers messages 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" }