Multiplayer

HTMLGameKit ships two components for real-time multiplayer games:

The lobby handles everything before two peers are connected; the match handles everything after.

By default this connects to https://signalling.htmlgamekit.dev - a free to use server for handling WebRTC signalling and setup.

How It Works

Browser A                 Signalling Server           Browser B
─────────                 ─────────────────           ─────────
game-lobby ──── WS ─────► room / queue ◄──── WS ──── game-lobby
game-peer-connection ◄─── SDP ────► relay         ────► SDP ──► game-peer-connection
game-peer-connection ◄─── ICE ────► relay         ────► ICE ──► game-peer-connection
game-peer-connection ◄──────────────── WebRTC DataChannel ──────────────► game-peer-connection

The signalling server relays SDP offers/answers and ICE candidates until both peers establish a direct connection. After that, all game data flows peer-to-peer over WebRTC DataChannels — the server is no longer in the path.

Element Placement

Both elements go inside <game-shell>, typically before any visible children. They have no visual output (static template = null):

<game-shell id="game" game-id="my-game" rounds="1">
  <game-lobby id="lobby" reconnect auto-ready></game-lobby>
  <game-peer-connection id="match" auto-ready></game-peer-connection>

  <!-- visible content here -->
</game-shell>

<game-peer-connection> discovers <game-lobby> automatically via the lobby context pushed onto the shell. The two elements do not need to be adjacent or in any particular order.

Matchmaking Flows

Matchmaking Queue

The simplest path: both players call joinQueue() and the server pairs them when two are available.

<button commandfor="lobby" command="--join-queue">Find a game</button>
const lobby = document.querySelector("game-lobby");
lobby.joinQueue();

The server fires game-lobby-match when a match is found, then game-lobby-start when signalling begins. <game-peer-connection> starts WebRTC automatically when startSignalling fires.

Private Room

One player creates a room; the other joins with the code.

<!-- Host -->
<button commandfor="lobby" command="--create-room">Create room</button>
<!-- Both players, once the room shows two players -->
<button commandfor="lobby" command="--lobby-ready">Ready</button>
// Host
lobby.createRoom();
// → game-lobby-room fires with { code, players }
// The room code is published as the "room-code" shell stat

// Guest
lobby.joinRoom("ABCD1234");
// → game-lobby-room fires on both sides

// Both players signal ready
lobby.ready();
// → the server starts signalling when all players are ready

--join-queue, --create-room, --join-room, --leave-room, --leave-queue, --lobby-ready, --lobby-unready and --set-preference cover the whole lobby API, so most lobby UIs need no JavaScript at all. See the <game-lobby> reference.

Use <game-signal key="room-code"> to display the room code in the UI.

Lobby State

The lobby pushes its state into shell stats on every transition, so <game-signal> and when-* conditions work without any custom code:

Stat Values
lobby-state connecting / connected / in-room / in-queue / signalling / handed-off / disconnected
player-count Number of players in the current room
room-code Room code string when in a private room
queue-position Position in the matchmaking queue
player-id This player's ID as assigned by the server
<div when-some-scene="intro" when-eq-lobby-state="in-queue" data-overlay>
  Finding a game...
</div>

Because lobby-state is a stat, when-* conditions can select which intro overlay is visible. when-no-lobby-state="in-room in-queue signalling" reads as "none of these", which covers the remaining states with one element. Do not use the hidden attribute for this: data-overlay sets display: flex, which overrides it.

game-shell.start() clears stats, but both elements republish theirs on the "setup" lifecycle event, so lobby and match state stay readable during play.

The lobby also exposes CSS custom states (:state(connecting), :state(in-room), etc.) for styling game-lobby itself directly.

Match State

<game-peer-connection> similarly pushes stats and exposes CSS states:

Stat Values
peer-state idle / signalling / connecting / connected / disconnected
latency Round-trip time in milliseconds (polled every 2 s)
peer-id Remote player's ID

CSS states mirror peer-state, plus :state(ready) while this player has signalled readiness and the peer has not.

Starting the Game

With auto-ready on <game-peer-connection>, both peers signal readiness as soon as the DataChannels open; the element then fires game-start-request and the shell calls .start(). No JavaScript needed.

Without it, drive the handshake yourself — from a button:

<button commandfor="match" command="--peer-ready">Start</button>

or call match.ready(), or skip the handshake entirely and call shell.start() from game-peer-connection-open.

shell.start() transitions the scene from intro to playing, making the game board visible and hiding the lobby UI.

Rematches

ready() clears both readiness flags once it has fired game-start-request, so the same handshake serves every subsequent game. Point a result-overlay button at the match and both players must agree before the next game starts:

<button commandfor="match" command="--peer-ready">Play again</button>

While this player is ready and the peer is not, the element carries :state(ready) — use it to swap the button for a "waiting for opponent" message in CSS.

Sending and Receiving Moves

<game-peer-connection> exposes two DataChannels — reliable (ordered, guaranteed delivery) and unreliable (unordered, no retransmits) — via a single send() method:

const match = document.querySelector("game-peer-connection");

// Reliable (default): game state, moves, scores
match.send({ type: "move", cell: 4 });

// Unreliable: real-time position, animation hints
match.send({ x: 120, y: 340 }, { reliable: false });

Incoming messages fire game-peer-connection-message on the match element, bubbling up through the shell:

shell.addEventListener("game-peer-connection-message", (e) => {
  const { data, channel, peerId } = e;
  // data is already JSON-parsed if the sender sent an object
});

Messages arrive on the same element as all other game events, so you can listen at the shell or at a parent game component.

Role Assignment

Both players need to agree on a role (e.g. who goes first, who is X vs O) without coordination. <game-peer-connection> itself uses this trick for deciding who creates the WebRTC offer: sort player IDs lexicographically — the lower ID is the offerer. Your game can apply the same logic:

const myId = match.localPlayerId;
const peerId = e.peerId; // from game-peer-connection-open
const iFirst = myId < peerId;

Both peers reach the same conclusion independently.

Handoff

Once both DataChannels are open and each side has finished gathering ICE candidates, the peers tell each other so over the reliable channel and <game-peer-connection> calls lobby.handoff(). The lobby sends handoff to the signalling server and closes its WebSocket; the server forgets the room. From here the game runs peer to peer with no server at all, so a signalling server restart mid-game changes nothing. The lobby sits in handed-off until a result is reported or the shell quits, either of which reconnects with the same player ID.

Because the lobby socket is gone, the lobby cannot tell you the peer left. <game-peer-connection> already covers that with its heartbeat: a peer that stops answering for heartbeat-timeout fires game-peer-connection-close.

Result Reporting

After a match, report the outcome to the server (for rating/ranking):

match.reportResult(peerId, "win"); // "win" | "loss" | "draw"

This calls through to lobby.reportResult(), which reconnects if the socket was handed off and sends a report_result message carrying the match token the server issued at start_signalling. Tokens last 24 hours. Confirmation and rating messages are informational and are not surfaced as events; listen for game-lobby-error if the report fails.

Disconnection

game-peer-connection-close fires when either DataChannel closes:

shell.addEventListener("game-peer-connection-close", (e) => {
  const { peerId, reason } = e;
  // reason: "closed" (reliable channel) | "unreliable-closed"
  // handle graceful or ungraceful disconnect
});

The match state transitions to disconnected. With reconnect on the lobby, the WebSocket reconnects automatically but WebRTC does not — the player needs to re-enter the queue or room to find a new peer.

Signalling Server

The url attribute on <game-lobby> defaults to https://signalling.htmlgamekit.dev, so omitting it connects to the public server. That server is available for development and small-scale games. For production use, deploy your own instance from github.com/anomalyco/signalling (Actix-Web, WebSocket, SQLite) and set url to point at it.

The shell's game-id attribute namespaces matchmaking — <game-lobby> reads it automatically from the shell. Players are only matched with others whose shell has the same game-id. Keep it unique to your game to avoid cross-game matchmaking.

See Also