GamePeerConnection

Attributes

auto-ready
boolean Automatically call ready() when both channels open; fires game-start-request once peer also ready
ice-timeout
number Defaults to 2000. Milliseconds to wait for each ICE server reply from the lobby
ice-attempts
number Defaults to 3. How many times to ask the lobby for ICE servers before giving up
connect-timeout
number Defaults to 20000. Milliseconds before an incomplete handshake is abandoned; 0 waits for ever
heartbeat-interval
number Defaults to 2000. Milliseconds between heartbeats to the peer; also the latency sample rate
heartbeat-timeout
number Defaults to 10000. Milliseconds of silence from the peer before the connection is reported lost; 0 never gives up
reliable-label .reliableLabel
string Defaults to reliable.
unreliable-label .unreliableLabel
string Defaults to unreliable.
max-retransmits .maxRetransmits
number Defaults to 0.

Methods

.define(tag, registry)
.ready()
Signal readiness to start. When both peers are ready, dispatches `game-start-request` to the shell and clears both readiness flags, so a later call starts a rematch. Equivalent to the `--peer-ready` command. Calling this before the DataChannels open is safe: readiness is sent to the peer as soon as the connection opens.
.send(data, { reliable = true }, opts)
Send data to the peer over a DataChannel. Data is JSON-serialised if not already a string.
.close()
Close the peer connection and reset to idle. Fires no `game-peer-connection-close` event; the peer is told by its own DataChannels closing.
.reportResult(opponent, outcome)
Report match result via the lobby signalling channel.
.subscribe(context, callback)

Events

no-servers GamePeerConnectionIceEvent
game-peer-connection-open GamePeerConnectionOpenEvent
Both DataChannels open and ready
game-peer-connection-close GamePeerConnectionCloseEvent
DataChannel closed, the peer left the room before the channels opened, the handshake timed out, or the peer stopped answering heartbeats
game-peer-connection-message GamePeerConnectionMessageEvent
Message received from peer
game-peer-connection-ice GamePeerConnectionIceEvent
ICE connection state changed, or "no-servers" when the lobby never returned STUN/TURN configuration

Custom States

StateDescription
:state(idle)No connection in progress
:state(signalling)Exchanging SDP and ICE
:state(connecting)ICE connecting
:state(connected)Both DataChannels open
:state(disconnected)Connection lost
:state(ready)Local player signalled readiness, waiting for the peer

Manages a WebRTC RTCPeerConnection with two RTCDataChannels — one reliable (ordered, guaranteed delivery) and one unreliable (unordered, no retransmits). Wires automatically to a sibling <game-lobby> via the lobby context for SDP and ICE relay.

Role determination (offerer vs answerer) is handled internally by sorting player IDs lexicographically: the lower ID becomes the offerer. Both peers reach the same conclusion independently.

Attributes

auto-ready
boolean Automatically call ready() when both channels open; fires game-start-request once peer also ready
ice-timeout
number Defaults to 2000. Milliseconds to wait for each ICE server reply from the lobby
ice-attempts
number Defaults to 3. How many times to ask the lobby for ICE servers before giving up
connect-timeout
number Defaults to 20000. Milliseconds before an incomplete handshake is abandoned; 0 waits for ever
heartbeat-interval
number Defaults to 2000. Milliseconds between heartbeats to the peer; also the latency sample rate
heartbeat-timeout
number Defaults to 10000. Milliseconds of silence from the peer before the connection is reported lost; 0 never gives up
reliable-label .reliableLabel
string Defaults to reliable.
unreliable-label .unreliableLabel
string Defaults to unreliable.
max-retransmits .maxRetransmits
number Defaults to 0.
reliable-label
string — Label for the reliable DataChannel. Defaults to "reliable". Change only if you need a custom label for server-side inspection or inter-app compatibility.
unreliable-label
string — Label for the unreliable DataChannel. Defaults to "unreliable".
max-retransmits
number — Maximum retransmit attempts for the unreliable channel. Defaults to 0 (fire-and-forget). Increase to allow limited retries while still avoiding head-of-line blocking.
auto-ready
boolean — When present, calls .ready() as soon as both DataChannels open. Once the peer is ready too the element fires game-start-request, which the shell answers by calling .start() — so the game begins with no JavaScript at all. Omit it when the players should confirm before the first game starts.
heartbeat-interval
number — Milliseconds between heartbeats sent to the peer over the reliable channel, and how often .latency is sampled. Defaults to 2000.
heartbeat-timeout
number — Milliseconds of silence from the peer (no heartbeat and no game message) before the connection is reported lost with reason "lost". Defaults to 10000. Set to 0 to wait for ever. A killed tab or a dropped network never closes its DataChannels, and ICE can take half a minute to notice, so this is what tells a turn-based game its opponent is gone.

Instance Properties

.peerId
string | null — Remote player's ID, or null if not connected.
.localPlayerId
string | null — This player's ID as assigned by the signalling server (reads from the sibling <game-lobby>).
.connected
booleantrue when both DataChannels are open.
.latency
number | null — Last measured round-trip time in milliseconds, polled every heartbeat-interval. null if not connected or stats unavailable.

Instance Methods

.send(data, options?)
Send data to the peer. Objects are JSON-serialised automatically; strings are sent as-is.

Parameters:

  • dataany — Payload to send.
  • options.reliableboolean — Defaults to true. Set to false to send over the unreliable channel (lower latency, no delivery guarantee).
// Reliable: game moves, state transitions
match.send({ type: "move", cell: 4 });

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

Calls are no-ops if the relevant channel is not open.

.close()
Close the peer connection and reset to idle. Fires no game-peer-connection-close event; the peer finds out through its own DataChannels closing. The match can reconnect if the lobby starts a new signalling session. Called automatically when the shell quits (shell.quit() / --quit).
.ready()
Signal that this player is ready to start. Readiness is sent to the peer over the reliable channel; calling it before the channels open is safe, the signal is sent as soon as they do. When both peers are ready the element fires game-start-request and clears both readiness flags, so calling .ready() again offers a rematch. Equivalent to the --peer-ready command:
<button commandfor="match" command="--peer-ready">Play again</button>
.reportResult(opponent, outcome)
Report a match outcome via the lobby signalling channel.

Parameters:

  • opponentstring — The opponent's player ID (use match.peerId).
  • outcome"win" | "loss" | "draw"
match.reportResult(match.peerId, "win");

Events

game-peer-connection-open
Fires once per connection, when both DataChannels are open and the connection is ready for game data. Use auto-ready to start the shell without writing this listener.
Property Type Description
peerId string Remote player's ID
shell.addEventListener("game-peer-connection-open", (e) => {
  console.log("Connected to", e.peerId);
  shell.start();
});
game-peer-connection-close
Fires when the connection is lost: the reliable DataChannel closed, the lobby reported that the peer left the room before the channels opened (the signalling server notices a closed tab straight away, long before ICE times out), the handshake never completed within connect-timeout, or nothing arrived from the peer for heartbeat-timeout. Once the channels are open the lobby is no longer consulted: the socket is handed off, and the heartbeat is what notices a vanished peer. Either way the element tears the connection down, moves to :state(disconnected) and drops any pending readiness. The unreliable channel closing on its own only fires the event.
Property Type Description
peerId string Remote player's ID
reason string "closed" (reliable channel), "left" (peer left the room), "timeout" (handshake never completed), "lost" (peer stopped answering heartbeats) or "unreliable-closed"
game-peer-connection-message
Fires when a message arrives from the peer.
Property Type Description
peerId string Sender's player ID
channel "reliable" | "unreliable" Which channel delivered the message
data any JSON-parsed payload (or raw string if not valid JSON)
shell.addEventListener("game-peer-connection-message", (e) => {
  const { data, channel, peerId } = e;
  if (channel === "reliable" && data.type === "move") {
    applyMove(data.cell);
  }
});
game-peer-connection-ice
Fires on ICE connection state changes, and once with the synthetic state "no-servers" when the lobby never returned STUN/TURN configuration. That case still attempts the connection, but it can only succeed between peers that reach each other directly, so it is worth surfacing in the UI.
Property Type Description
state string ICE connection state string, or "no-servers"

All events bubble and compose.

CSS States

State When active
:state(idle) No connection attempt in progress
:state(signalling) Exchanging SDP and ICE candidates
:state(connecting) ICE negotiation in progress
:state(connected) Both DataChannels open
:state(disconnected) Connection lost or peer left
:state(ready) This player is ready, peer is not

:state(ready) makes a rematch prompt purely declarative — the element sits before the overlays in the shell, so a sibling selector reaches it:

game-peer-connection:state(ready) ~ [data-overlay] [data-rematch],
game-peer-connection:not(:state(ready)) ~ [data-overlay] [data-rematch-wait] {
  display: none;
}

Shell Stats

Stat Type Description
peer-state string Current state name
latency number|null Round-trip time in ms
peer-id string Remote player's ID
ice-servers number How many ICE servers the lobby gave

game-shell.start() clears stats, so the element republishes these on the "setup" lifecycle event. A latency readout keeps working across rounds and rematches without any bookkeeping in your game.

Usage

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

  <div when-some-scene="playing">
    ping <game-signal key="latency"></game-signal>ms
  </div>
</game-shell>
const shell = document.querySelector("game-shell");
const match = document.querySelector("#match");

// auto-ready starts the shell; capture the role when the connection opens
let amFirst = false;
shell.addEventListener("game-peer-connection-open", (e) => {
  // Deterministic role assignment — no coordination needed
  amFirst = match.localPlayerId < e.peerId;
});

// Handle incoming moves
shell.addEventListener("game-peer-connection-message", (e) => {
  if (typeof e.data.cell === "number") {
    applyOpponentMove(e.data.cell);
  }
});

// Send a move
function makeMove(cell) {
  match.send({ cell });
}

// Handle disconnect. The unreliable channel can close on its own, so only
// "closed" and "left" mean the game is over.
shell.addEventListener("game-peer-connection-close", (e) => {
  if (e.reason !== "unreliable-closed") returnToLobby();
});

See the Multiplayer concept page and the Noughts & Crosses tutorial for complete integration examples.