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.
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] });| Reason | Trigger |
|---|---|
ice-failed | The channel failed to open after the capped retries, 3 s each by default. |
rendezvous-unreachable | The signaling adapter threw on join. |
channel-closed | No pong for heartbeatTimeoutMs (6 s). |
room-evicted | The 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.
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:
- The heartbeat fails.
recoveryPhase()becomeshost-absent, andchannel-closedfires. - It waits
reconnectTimeoutMs(10 s). - iOS: the phase becomes
degraded. Show "scan the code again". Others: it rejoins the same code once, and a failed rejoin also ends indegraded. - 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:
- While playing. After a broadcast, Room saves
{ roomCode, hostToken, snapshot, sSeq }to IndexedDB (debounced), and tolocalStoragewhen the page is hidden or the app stops. - On load.
createAppfinds the saved record. Role becomeshost, the room code comes back, signaling reconnects, androom:host-reconnectingfires.
So a stage page must check before it creates a room.
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);- The slices do not come back by themselves. Register them on every load.
createRoomthrows "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.readreturnsundefineduntil you register slices. Keep state you must not lose yourself, then pass it tosync.importSnapshot(snapshot, sSeq). - The record is never deleted.
leave()andstop()write it, and nothing removes it. Every later load of that page on that device restores the old room. To truly start over, remove themoku.room.reentry.*keys fromlocalStoragebeforecreateApp. - Intents are not buffered while the stage is away. The buffer API exists, but no plugin calls
setBuffering. Intents sent in that window end inroom:intent-undeliverable.bufferedCount()stays0. rejointhrows without a stored room. Call it only whenrecoveryPhase()isdegraded.
Related
- session plugin:
recoveryPhase,rejoin,leave. - hub plugin: reclaiming a warm room on your own signaling.