Controller+BETADownload

Connection quality and reconnects

Keep a player's score, role and position attached to playerId. A better route changes how input arrives; it does not create a player. A lost connection reserves the seat for five minutes. Remove the player when removed fires.

These recipes work with ControllerHost and RelayRoom. Use api = host or api = room. Register listeners before players join; seed from api.players when attaching later.

Show ping and mobile data per player

Every player has connection: { state, route, network, pingMs } and reservedUntil. player-connection reports changed snapshots at most once every two seconds per player. Seat transitions are immediate. The map holds the latest received value even while its event is waiting.

export function connectionBadges(api, container) {
  const rows = new Map();
  function draw(id) {
    const p = api.players.get(id);
    if (!p) { rows.get(id)?.remove(); rows.delete(id); return; }
    let row = rows.get(id);
    if (!row) { row = document.createElement('p'); rows.set(id, row); container.append(row); }
    const c = p.connection;
    const ping = c.pingMs === null ? 'Ping —' : `Ping ${c.pingMs} ms`;
    const offline = c.state === 'reconnecting';
    const deadline = offline && p.reservedUntil
      ? ` · seat kept until ${new Date(p.reservedUntil).toLocaleTimeString()}` : '';
    row.textContent = `${p.name} · ${offline ? 'Reconnecting…' : ping}` +
      (!offline && c.network === 'cellular' ? ' · Mobile data: try Wi-Fi' : '') + deadline;
    row.style.opacity = offline ? '0.5' : '1';
  }
  const subscriptions = [];
  for (const type of ['join', 'disconnect', 'removed', 'player-connection']) {
    const listener = ({ playerId }) => draw(playerId);
    const off = api.on(type, listener);
    subscriptions.push(typeof off === 'function' ? off : () => api.off(type, listener));
  }
  for (const id of api.players.keys()) draw(id);
  return () => { for (const off of subscriptions) off(); for (const row of rows.values()) row.remove(); };
}

Show the warning only while the latest report says cellular. Wi-Fi, Ethernet, other and an explicit unknown all clear it. An omitted report supplies no new network information. A path upgrade clears the old ping until a fresh measurement arrives.

network is wifi, cellular, ethernet, other or unknown. Safari and some other browsers cannot identify it. Unknown does not mean mobile data or offline. Do not infer mobile data from effectiveType: '4g'.

route is local, peer, turn, relay, developer or unknown. Both APIs use these names in connection. The older RelayRoom route event keeps lan/p2p. room.on('connection') still measures the game's socket; use player-connection beside phones.

Ping is a phone-reported, rounded round trip, or null. On WebRTC it reaches the game. On the relay socket it reaches the relay endpoint, excluding the relay-to-game hop. It does not measure touch-to-screen delay or prove which radio is in use.

Pause only the player who lost connection

import { NEUTRAL } from '@controller-plus/sdk';

export function roundPlayers(api) {
  const progress = new Map();
  function join({ playerId, name }) {
    let p = progress.get(playerId);
    // A resumed join keeps this record, including its score and position.
    if (!p) { p = { score: 0, x: 0, y: 0, input: NEUTRAL() }; progress.set(playerId, p); }
    p.name = name;
    p.paused = api.players.get(playerId).connection.state !== 'connected';
    p.reservedUntil = api.players.get(playerId).reservedUntil;
  }
  api.on('join', join);
  for (const p of api.players.values()) join({ playerId: p.id, name: p.name });
  api.on('input', ({ playerId, input }) => {
    const p = progress.get(playerId); if (p && !p.paused) p.input = input;
  });
  api.on('disconnect', ({ playerId, reservedUntil }) => {
    const p = progress.get(playerId);
    if (p) { p.paused = true; p.input = NEUTRAL(); p.reservedUntil = reservedUntil; }
  });
  api.on('player-connection', ({ playerId, connection }) => {
    const p = progress.get(playerId);
    if (p) { p.paused = connection.state !== 'connected'; if (p.paused) p.input = NEUTRAL(); }
  });
  api.on('removed', ({ playerId }) => progress.delete(playerId));
  return progress;
}

In your game tick, skip movement, firing and scoring for p.paused; keep other players running. A racer must stop that car's simulation and collision pushes. A co-op game can make the paused character inactive. Apply your game's fairness rules to timers and damage. The SDK neutralizes input, but cannot stop a car your game accelerates automatically.

On join with resumed: true, send the current round's private view if it changed during the absence: host.setView(id, view) or room.view(id, view). The saved view otherwise returns automatically. Do not reset a resumed player's score, reroll a secret role, or replay a purchase or reward merely because it rejoined.

Wait for the final seat event

disconnect is { playerId, name, reservedUntil }. The deadline is Unix milliseconds from the host, or null when an older relay did not provide the exact expiry. In that case, show reconnecting and wait for the roster; do not start a new five-minute timer on every status update.

removed is { playerId, name, reason: 'expired' | 'removed' }. Delete that player's simulation entity, input and private game state, or apply your game's spectator rules. A later new join gets a new ID. A displayed deadline is informational: the host decides whether a resume ticket still qualifies.

The legacy leave event still fires on disconnect and on removal of a connected player. ControllerHost's older disconnected alias remains. New games should use disconnect to pause and removed to delete; deleting on every leave loses progress during a brief outage.

Network changes during play

Session checks for a better route after navigator.connection changes, online, and returning to a visible page. While on relay or TURN, it also checks every 30 seconds while visible. Custom clients can call session.checkPath() after their platform reports a change.

A fresh peer connection is the equivalent of restarting ICE and works with the Node peer adapter, which cannot restart an existing ICE connection. The current path carries input throughout the attempt. The phone and game exchange handoff support in their peer offer and answer, independently of the relay's feature list. A supporting candidate must open its channels, identify its selected route and answer a probe within 700 ms. A late reply closes the candidate and keeps the working path; a later check can try again. Silence never identifies a legacy host. Local and direct peer paths take priority. A TURN candidate replaces a working relay socket only when its probe is more than 5 ms faster; a worse candidate is closed.

Supporting peers drain input on the old ordered channel before activating the new one. Input generated during the drain is queued in order, then delivered on the new channel. Sequence tracking spans both paths. The seat, held buttons and press edges stay the same, with no reconnect state for a route check. The controller updates its route and ping and records the improvement in Connection details. Backgrounding still releases controls deliberately.

A cancelled drain closes its candidate and replays queued input on the usable route. If a replay send fails, the failed frame and remaining queue move to the socket before the latest state is refreshed. Failed adoption leaves the socket active. If the active peer fails during a drain, input returns to the socket, the candidate closes, and a fresh check runs after the two-second retry limit. A late drain acknowledgement cannot activate a cancelled candidate.

The phone refreshes the relay watchdog with the first fallback state, sends a retirement signal and ordered return checkpoint for the current peer and any previous peer, then repeats that state before sending the rest of the queue. The game can still own the previous peer if socket fallback arrives before candidate activation or RTC closure. An updated game accepts the checkpoint even when signaling is rate limited. It retires the matching active link or pending candidate, so a late activation cannot take over after fallback. Candidates with other link IDs are preserved. This protects held input from an old relay watchdog release while preserving queued taps. Healthy peer input remains protected from synthetic neutral releases, and the game's own stale-input timeout remains active. A host candidate also closes if either required channel closes or it has not activated within seven seconds of opening, including when cancellation signaling is lost.

If every usable route drops, the usual reconnect happens: input clears, the same ID remains reserved, and join with resumed: true restores play. Reloading the game page still requires your own game-state persistence.

Example games

Bumper Circuit (in the SDK download: examples/games/racer.mjs) and Orbit Crew (in the SDK download: examples/games/arena.mjs) expose connections(gameState, api, inputs), returning an unsubscribe function. Call it once per round:

import { createGame } from '@controller-plus/sdk/games';
const { module: game, state } = createGame('bumper-circuit', players, 42);
const inputs = {};
const onInput = ({ playerId, input }) => { inputs[playerId] = input; };
const offInput = api.on('input', onInput);
const detach = game.connections(state, api, inputs);
// Each simulation tick: game.step(state, inputs, dt).
function endRound() {
  detach();
  if (typeof offInput === 'function') offInput();
  else api.off('input', onInput);
}

Both keep scores and positions through a reserved disconnect, pause only the absent entity, show ping/mobile-data/reconnecting badges, and remove the entity on final seat removal. Other racers and crew members keep playing.

Compatibility

Phones send socket reports only after connection-info is accepted in hello/welcome. Host sockets receive those reports only when they accepted it too. seat-lifecycle adds authoritative deadlines and disconnect reasons to the roster and device-left. An older phone supplies unknown ping; an older relay supplies unknown fields. A new peer can also report directly after the peer handoff handshake, including through an older signaling relay.

An older phone can still activate its first peer with a new game. A new phone uses first-link compatibility only after receiving a legacy answer without path-handoff in its peer extension list. A timely path-ready from an earlier game still selects the ordered drain. A working legacy peer is kept: a fresh offer could make an old host close it prematurely. Relay fallback can still upgrade to a first peer link. Peer negotiation needs no new relay feature. Deploy a supporting relay to obtain socket-path ping reports and exact reservation deadlines through RelayRoom. See the wire contract.

Full fallback ordering requires an updated phone and game and a relay that forwards input and ordinary player events in order. An older game ignores the return checkpoint and depends on the relay accepting bye or on observing RTC closure before JSON replay. An older phone lacks the watchdog refresh and checkpoint. See peer return compatibility.