Skip to content
Moku
@moku-labs/room pages

Failures and reloads

What the app sees when the network fails, a phone dies, or the stage reloads, and what Room does not recover.

On this page

Surface network warnings

Room cannot fix a network that blocks peer connections. It tells you, and the UX is yours.

src/warnings.ts
import { createApp, createPlugin, stagePlugin } from "@moku-labs/room";

const messages = {
  "ice-failed": "Phones cannot reach this screen on this Wi-Fi.",
  "rendezvous-unreachable": "Cannot reach the signaling network.",
  "channel-closed": "A player's connection dropped.",
  "room-evicted": "This room expired."
};

const statusPlugin = createPlugin("status", {
  depends: [stagePlugin],
  hooks: ctx => ({
    "room:network-warning": ({ reason }) => ctx.log.warn(messages[reason])
  })
});

export const app = createApp({ plugins: [stagePlugin, statusPlugin] });
ReasonTrigger
ice-failedThe channel failed to open after the capped retries, 3 s each by default.
rendezvous-unreachableThe signaling adapter threw on join.
channel-closedNo pong for heartbeatTimeoutMs (6 s).
room-evictedThe hub tore an idle room down. serverSignaling only.

A phone disappears

The heartbeat pings every 2 s. After 6 s of silence the stage removes the phone from the roster and emits room:peer-left. The heartbeat is the only detector, because iOS does not fire the channel's onclose.

An intent cannot be delivered

Intents are retransmitted until the stage acknowledges them: after 1 s, 2 s, 4 s and 8 s by default. Then the phone gives up, and every queued intent drops with its own event.

src/pad.ts
import { controllerPlugin, createApp, createPlugin } from "@moku-labs/room";

const padPlugin = createPlugin("pad", {
  depends: [controllerPlugin],
  hooks: ctx => ({
    "room:intent-undeliverable": ({ name, cSeq }) => ctx.log.warn("pad:lost", { name, cSeq })
  })
});

export const app = createApp({ plugins: [controllerPlugin, padPlugin] });

An acknowledgement means "received", not "applied". A dropped invalid intent is still acknowledged.

The stage reloads

What happens on the phone:

  1. The heartbeat fails. recoveryPhase() becomes host-absent, and channel-closed fires.
  2. It waits reconnectTimeoutMs (10 s).
  3. iOS: the phase becomes degraded. Show "scan the code again". Others: it rejoins the same code once, and a failed rejoin also ends in degraded.
  4. The channel opens again. The phase returns to stable, and the phone gets a new snapshot.

room:host-reconnecting does not reach phones. Poll app.session.recoveryPhase() there.

What happens on the stage:

  1. While playing. After a broadcast, Room saves { roomCode, hostToken, snapshot, sSeq } to IndexedDB (debounced), and to localStorage when the page is hidden or the app stops.
  2. On load. createApp finds the saved record. Role becomes host, the room code comes back, signaling reconnects, and room:host-reconnecting fires.

So a stage page must check before it creates a room.

src/boot.ts
import { app } from "./stage";

await app.start();

app.sync.registerSlice("scores", {});

const { role, roomCode } = app.session.self();
const code = role === "host" ? roomCode : app.stage.createRoom().code;
console.log(code);
  1. The slices do not come back by themselves. Register them on every load.
  2. createRoom throws "A room is already active" after a reload.
  • The game state is not restored. The record is saved, but the package never loads it back. After a reload sync.read returns undefined until you register slices. Keep state you must not lose yourself, then pass it to sync.importSnapshot(snapshot, sSeq).
  • The record is never deleted. leave() and stop() write it, and nothing removes it. Every later load of that page on that device restores the old room. To truly start over, remove the moku.room.reentry.* keys from localStorage before createApp.
  • Intents are not buffered while the stage is away. The buffer API exists, but no plugin calls setBuffering. Intents sent in that window end in room:intent-undeliverable. bufferedCount() stays 0.
  • rejoin throws without a stored room. Call it only when recoveryPhase() is degraded.