# Realtime Rooms — party games in RCF 2.8

Start at https://riff.marantz.net/create/ and resolve current files through https://riff.marantz.net/create/manifest.json . This is the builder contract for `services: ["realtime-room"]`, protocol `party/1`. Use it for one TV/projector/Zoom host plus phone controllers: trivia, quizzes, drawing prompts or call-the-play games. Keep an offline/local mode when connectivity, capacity or the host is unavailable.

This document describes the release contract. Check the published platform and your actual preview receipt; a source file alone is not deployment evidence. Camera, microphone, motion and other device permissions remain separate. Do not add `externalNetwork` for rooms.

## What the platform does

A Cloudflare Durable Object persists and orders each room's state and authenticates commands. The directory also performs command preflight checks; it is not exclusively a one-time admission hop. Hibernating WebSockets deliver snapshots and presence through the platform-owned preview parent. The app runs locally and controls its game rules and scoring. This is host-authoritative party play, **not server-validated anti-cheat**, a generic backend, a durable leaderboard or an account system.

```text
Host app ── bounded bridge ── Riff preview parent ─┐
                                                ├─ Riff API → one room object
Phone app ─ bounded bridge ── Riff preview parent ─┘              │
                                    snapshots / presence ←──────┘
```

The artifact iframe stays opaque. The parent verifies the active frame and the manifest's service declaration, pins the submission ID and package SHA-256, and owns HTTP/socket credentials. The child cannot select another app identity, arbitrary URL or network destination. No declared service means no room bridge access. The artifact CSP continues to block arbitrary networking; do not open sockets or fetch the service directly from the app.

A room belongs to **one exact uploaded edition**. Players must open that edition's canonical preview URL, even if a newer revision exists. A different revision or another app cannot join the room. Discovery approval is unrelated to room authority. Preserve the RCF 2.7 revision protocol, creator attribution and immutable receipts.

The pinned modules are also readable/downloadable from https://riff.marantz.net/shared/provisional/1.1.0/room.js and https://riff.marantz.net/shared/provisional/1.1.0/party-shells.js . Inside an uploaded app, import the same absolute paths; the artifact host serves the identical pinned bytes. A direct top-level file has no trusted room bridge.

## Manifest and imports

Set these fields in the ordinary upload manifest, retaining all other required fields:

```json
{
  "rcfVersion": "2.8.0",
  "services": ["realtime-room"]
}
```

Import the published pinned modules; do not copy credentials or bundle a substitute network client:

```js
import { createRoomClient } from '/shared/provisional/1.1.0/room.js';
```

Optional layout helpers live at `/shared/provisional/1.1.0/party-shells.js`: `createHostProjector`, `createPlayerController`, `PARTY_PHASES`, `PARTY_SHELL_CSS` and `sanitizeTheme`. Inspect the pinned module for constructor arguments before use. They provide reusable host/projector and phone-controller presentation, not game rules or an authentication layer. They must retain readable status, keyboard access, 44px controls and reduced-motion behavior when themed. The prior provisional runtime 1.0.1 remains stable; adding room support does not change its in-memory analytics/storage behavior.

Use the hosted preview wrapper, not the raw artifact URL. Direct artifact visits cannot use the parent bridge and must show an actionable fallback. Share the receipt's canonical preview URL plus your app's bounded public hash state. A room code may be included as an invite; never include host/member tokens, answers or private player events.

## Optional host and player shells

Both constructors take a DOM root and an options object, return `.render(view)` and `.destroy()`, and install their CSS by default. `createPlayerController` also exposes `.focusMain()`. Shared options are `clock` (server-adjusted epoch milliseconds), `theme`, `labels` and `injectStyles`; the host also accepts `title`. Player callbacks are `onJoin({ code, name })`, `onAnswer({ index, choice })`, `onRetry()` and `onLeave()`. Handle asynchronous callback failures in your app; these callbacks do not automatically connect or mutate a room.

The shell takes a **presentation view model**, not a raw room snapshot. Map `snapshot.state.prompt` to `view.question.text`, `snapshot.state.choices` to `view.question.choices`, and `snapshot.members` to `view.players`. The app supplies `answer.status` (`sending`, `sent`, `error`), connection notices, revealed results and scores. “Sent” means your send resolved, not that it earned points. Example host adapter:

```js
import { createHostProjector } from '/shared/provisional/1.1.0/party-shells.js';
const projector = createHostProjector(document.querySelector('#stage'), {
  title: 'Cabinet Clash Trivia',
  clock: () => Date.now() + serverOffset,
  theme: { background: '#101820', accent: '#ffd23f' }
});
function renderProjector(snapshot) {
  const state = snapshot.state;
  projector.render({
    phase: state.phase, code: snapshot.code,
    hostConnected: snapshot.hostConnected,
    players: snapshot.members,
    question: { text: state.prompt, choices: state.choices },
    endsAt: state.endsAt, scores: state.scores,
    reveal: state.reveal
  });
}
// On experience teardown: unsubscribe listeners, dispose the room/client,
// and projector.destroy() to stop shell timers and remove its DOM.
```

A player starts with `.render({ phase: 'join', code: invitedCode })`. On successful join, render the mapped room view with `playerName`; on a pending join use `busy: true`, and on failure supply `error`. For answer UI use `answer: { index, status, message }`. Scores use `{ id, name, score, rank }`; reveal uses `reveal: { correct: [choiceIndex], note }` and an optional player-specific `result: { correct, points }`. Labels/theme affect presentation only. `PARTY_PHASES` covers shared game phases; the player additionally renders `join` and `ended`. Your app must disable submission when offline or when the host is away; a connection banner alone does not enforce that game rule.

## Invite URLs

Build an invite from the exact `previewUrl` in the submission receipt. The application must read the hash and offer the player join action; the platform forwards the hash but does not interpret it as an automatic join:

```js
const invite = new URL(receipt.previewUrl, 'https://riff.marantz.net');
invite.hash = new URLSearchParams({ room: room.code }).toString();
// Example shape: https://riff.marantz.net/create/preview?id=sub_...#room=ABC234
const invitedCode = new URLSearchParams(location.hash.slice(1)).get('room') || '';
```

Display/copy `invite.href` or use it as the share payload. Preserve your own bounded app hash fields if you already use them. Never add host/member keys, private choices or answer keys. A recipient still chooses a public player label and joins; knowing the code cannot recover the host role.

## SDK contract

`createRoomClient()` returns:

- `createRoom({ initialState = {} })` → host Room.
- `joinRoom(code, { playerLabel })` → member Room.
- `resumeRoom(code)` → restored Room when this browser's stored authority remains available.
- `dispose()` → release client resources.

A Room exposes `code`, `role` (`host` or `member`), optional `memberId`, `snapshot`, and:

| Method | Meaning |
| --- | --- |
| `on(type, callback)` | Subscribe to `state`, `presence` or `error`; returns an unsubscribe function. Render the initial `snapshot` too. |
| `replaceState(state, { expectedVersion, operationId } = {})` | Host replaces bounded public JSON. Default expectedVersion is the current snapshot version. A stale version conflicts; reload/reconcile rather than blindly overwrite. |
| `send(event, { operationId } = {})` | Member sends a private event to the host inbox. There is no arbitrary player-to-player send. |
| `ack(eventIds, { operationId } = {})` | Host acknowledges consumed inbox entries. Process each event ID once. |
| `moderate({ joinLocked: true })` or `moderate({ removeMemberId: id })` | Host locks/unlocks new joins or revokes a member. Supply at least one option. Pass `joinLocked: false` to reopen. Removed credentials cannot resume. |
| `leave()` | Leave the room; do not use this merely to suspend rendering. |
| `close()` | Host closes the room for everyone. |
| `dispose()` | Detach transport/listeners without deleting the room. |

For application retries, reuse the same random 32-hex `operationId` for the same action. Do not announce a score or answer as accepted until the command resolves; show errors. The SDK timeout is not proof the server rejected a command.

Snapshots contain `protocol`, `code`, `scope`, `state`, `version`, `serverNow`, `expiresAt`, `members`, `hostConnected`, `joinLocked`, `role` and optional `memberId`. Only hosts receive `inbox`, whose entries include `id`, `memberId`, `event`, server `at` and `acceptedVersion` (the public state version when accepted). Presence describes connections, not verified people. `state` events carry the full projected snapshot; `presence` carries `{ members, hostConnected }`; `error` carries `{ code, message }`.

Suggested public state:

```js
{
  phase: 'question', // lobby | question | lock | reveal | scores
  questionId: 'q1',
  endsAt: 1791324060000, // server epoch milliseconds, not a local countdown
  prompt: 'Which bird can hover?',
  choices: ['Hummingbird', 'Ostrich', 'Penguin'],
  scores: []
}
```

Do not publish the correct answer until reveal, or each player's unrevealed choice. An opaque frame does not make downloaded questions/answers secret: a determined player can inspect client assets. Send private choice events to the host and publish only the intended lock status and aggregate/revealed result. Derive deadlines from server time and handle late answers explicitly in the host app. The platform does not grade or enforce the meaning of your phases.

## Minimal trivia integration

This deliberately uses ordinary UI callbacks so the transport is independent of a layout library. Bind them to visible buttons and supply your own rendering/error functions:

```js
import { createRoomClient } from '/shared/provisional/1.1.0/room.js';
const client = createRoomClient();
let room;
let serverOffset = 0;
function receive(snapshot) {
  serverOffset = snapshot.serverNow - Date.now();
  render(snapshot);
}
function attach(next) {
  room = next;
  receive(room.snapshot);
  room.on('state', receive);
  room.on('presence', renderPresence);
  room.on('error', showError);
}
async function hostGame() {
  attach(await client.createRoom({ initialState: { phase: 'lobby', scores: [], processedEventIds: [] } }));
  showInvite(room.code); // pair with this edition's preview URL
}
async function joinGame(code, playerLabel) {
  attach(await client.joinRoom(code, { playerLabel }));
}
async function showQuestion() {
  await room.replaceState({
    phase: 'question', questionId: 'q1',
    prompt: 'Which bird can hover?',
    choices: ['Hummingbird', 'Ostrich', 'Penguin'],
    endsAt: Date.now() + serverOffset + 20000,
    scores: room.snapshot.state.scores ?? [],
    processedEventIds: room.snapshot.state.processedEventIds ?? []
  });
}
async function choose(choice) {
  await room.send({ type: 'answer', questionId: 'q1', choice });
  showWaiting(); // accepted into inbox, not proof the answer earned points
}
```

The sample estimates server clock offset from each received snapshot; network delay still affects that estimate. Catch rejected callbacks in your UI and keep controls disabled while an action is pending. Serialize the host scoring handler and derive each next score state after the preceding update is acknowledged; do not compute parallel score replacements from the same old snapshot. The host must validate event type, active question, allowed choice, deadline and duplicate member answers; compute score; replace public state with the new scores and bounded `processedEventIds` together; then acknowledge handled inbox IDs. On recovery, skip scoring an event already recorded and retry its acknowledgement. State replacement and inbox acknowledgement are separate commands, so the platform does not guarantee exactly-once scoring. A local-only processed set is insufficient after refresh. Keep this deduplication record free of answer contents, within the state budget, and retain IDs while their events could still be retried. The transport does not execute creator-supplied server code. Do not assume `send()` means an answer has been graded.

Snapshot `sequence` increases for every accepted persistent mutation; `version` increases only for public state replacement and is the compare-and-swap value. The trusted wrapper rejects stale snapshots so a delayed HTTP response cannot erase a newer private inbox update. Equal-sequence presence updates remain valid.

## Authority, reconnect and lifecycle

The six-character code is an invitation to join as a player, never host authority. Host/member bearer tokens remain in the parent and guarded browser storage; they are not sent to the child, URLs or analytics. Same-browser restore is best effort. Storage denial, cleared storage, another profile or another device can prevent recovery. Offer a clear rejoin/new-room action; do not promise account recovery.

Use separate browser profiles for a host and a player: one browser cannot hold both roles for the same edition/code at once. Host disconnection sets `hostConnected: false`; pause/wait visibly. There is no automatic host election or migration in v1. Dispose/detach while hidden or leaving the experience, and resume through the SDK when appropriate. The host's close action is different from suspending a tab. Offer visible host controls to lock joins and remove a disruptive member. Removal revokes that member's credential and removes it from the visible roster; it does not recycle the lifetime admission slot. A room code is shareable, so removal alone is not verified-person banning. Lock joins when appropriate. Member event limits protect a bounded host command reserve; do not treat the service as unlimited abuse prevention.

## Pilot bounds

| Limit | Bound |
| --- | --- |
| Lifetime | 2 hours from creation; not a sliding idle extension |
| Players | 16 admitted members over the room lifetime, plus one host. Leaving disconnects but retains the member identity/slot for recovery. |
| Connections | 17 concurrent sockets |
| Public state | 32 KiB JSON, at most 20 nesting levels |
| Private player event | 1 KiB JSON |
| Host inbox | 100 unacknowledged events total; at most eight pending per member |
| Member events | 20 per member per minute; 150 per member over the room lifetime |
| Lifetime command budgets | 700 member events across the room plus 300 host commands; 1,000 total. Host close is exempt so the host can always close. |
| Host command rate | 120 per minute, separate from per-member event limits |
| Admission | 100 room creations over this pilot's lifetime, separate from the existing collaboration directory; five creates per trusted network per day |

The 100-creation lifetime ceiling is an explicit pilot spending safeguard. Reaching it requires operator review of usage and spending authorization before changing the ceiling; do not silently expand or reset it. These are application limits, not a service-provider billing cap or a guaranteed concurrency SLA. Capacity exhaustion is a real error: preserve offline play and report it. Do not create new identities to bypass limits. Do not stream positions, raw audio/video, drawings per pointer move or high-frequency animation through a room. Batch meaningful events; render animation locally. No creator-specific paid tier or quota override is implied.

## Local and hosted acceptance

Use the published `preview.mjs` rather than an arbitrary file server. Local room testing requires a running local collaboration service, connected with `RIFF_ROOM_ORIGIN`; the preview parent uses the same bounded bridge and service declaration checks. Do not proxy the live room service with private production credentials. An ordinary preview without a configured room service must explain unavailability and keep offline mode usable. For platform contributors with this repository and an already installed Wrangler, build the static assets and start the local service in one terminal:

```sh
npm run build
wrangler dev --config server/collaboration/wrangler.local.jsonc --port 8792
```

Then, in a second terminal, run the published helper with your package:

```sh
RIFF_ROOM_ORIGIN=http://127.0.0.1:8792 node preview.mjs ./my-rifflet.rifflet.json --port 4291
```

Open `http://127.0.0.1:4291/` in separate browser profiles for host and player. For repository-local helper use, replace `preview.mjs` with `scripts/contributions/preview.mjs`. Use a fixed preview port for restore testing, because parent storage belongs to that exact origin. Only a loopback HTTP origin is accepted as the upstream. Local identity is derived from the package digest, so both clients must preview identical package bytes. Local rooms are separate from hosted rooms and cannot join them. A third-party builder without platform service source can test offline locally and test rooms through the uploaded exact-edition hosted preview; the standalone preview download does not install or launch a room backend.

Verify in separate browser profiles:

1. Host creates; player joins by code; another player joins mid-question and receives current state.
2. Host changes phase; both players update without typing question/sync numbers. Measure actual latency; approximately 500 ms is a normal-connection target, not a guarantee.
3. A player's event reaches only the host inbox. Host acknowledges once; refresh/retry cannot double-score.
4. Host refresh restores when stored authority exists; blocked storage has a clear fallback. Host disconnect, expiry, quotas and stale-state conflicts are visible.
5. Undeclared service, different edition/digest, spoofed frame and unknown code are rejected.
6. Player cannot replace state, moderate, close the room or gain host authority using only the code. A locked room rejects new joins; a removed member cannot resume, and its lifetime slot stays used.
7. Local preview and hosted exact-edition preview render correctly at phone and desktop sizes. Record physical-device evidence separately.

Room messages are gameplay coordination, not automatic product analytics. Do not claim hosted provisional telemetry, persistent rankings or user identity through this feature. Avoid real names and personal data; show public labels and room expiry. WebRTC/P2P, STUN/TURN, voice/video chat and automatic host migration are deferred.

## Premium deterministic phase rooms (`party/2`)

`party/2` is an additive premium protocol and does not change the frozen `party/1` contract above. It is implemented and locally verified, but remains unavailable unless the exact public submission has a live `premium-v1` entitlement and the deployment switch and internal service key are configured. New apps import `/shared/provisional/1.5.0/room.js` (the original 1.3.0 pin remains immutable); the trusted preview attaches its separate `rifflet.rooms/2` bridge only when the immutable manifest declares `realtime-room-v2` and a valid `roomPlan`.

The manifest plan is declarative. Server timings and transitions are fixed; uploaded code cannot run on the server:

```json
{
  "services": ["realtime-room-v2"],
  "roomPlan": {
    "schemaVersion": "rifflet.phase-plan/1",
    "lobbySchema": {"fields": [
      {"name": "st", "type": "enum", "values": ["creator-style-a", "creator-style-b"]}
    ]},
    "questionsSchema": {"fields": [
      {"name": "answer-picks", "type": "array", "minItems": 3, "maxItems": 3,
       "items": {"type": "enum", "values": ["creator-answer-a", "creator-answer-b", "creator-answer-c"]}},
      {"name": "plan", "type": "enum", "values": ["creator-plan-a", "creator-plan-b"]}
    ]},
    "campSchema": {"fields": [
      {"name": "stat-1", "type": "integer", "minimum": -3, "maximum": 3},
      {"name": "stat-2", "type": "integer", "minimum": -3, "maximum": 3},
      {"name": "stat-3", "type": "integer", "minimum": -3, "maximum": 3},
      {"name": "stat-4", "type": "integer", "minimum": -3, "maximum": 3},
      {"name": "stat-5", "type": "integer", "minimum": -3, "maximum": 3},
      {"name": "arena-vote", "type": "enum", "values": ["creator-arena-a", "creator-arena-b"]}
    ]}
  }
}
```

The names and enum values above are candidate shapes, not agreed Mana Royale meanings. A reviewed creator manifest supplies its own bounded values. It may declare a public `lobbySchema`; `st` is the intended entrance-style field. Each player or host may set only their own schema-valid lobby metadata before start. It appears publicly as `members[].lobby`, starts as `null`, and freezes at start. Spectators cannot set it. The lobby command cannot target another member or alter a pending or sealed phase input.

Each lobby or phase schema has one to eight exact fields. Supported values are booleans, safe integers with declared minimum/maximum, enums of at most 32 short slug values, and arrays of at most 16 of those scalar types. Freeform strings, extra keys, prototype keys and inputs over 256 serialized bytes are rejected. Structural validation cannot determine a field's human meaning: the app review must ensure these are public lobby presentation or derived gameplay decisions. Raw food, travel, personality or other preference answers must stay out of lobby metadata, room input, sealed state, analytics and logs. They may be persisted only through the owning player's private save or settings API with its export, erase and infrastructure-access disclosure.

After the host starts, three rounds advance through questions (80 seconds), play (at most 100 seconds) and camp (40 seconds), then final. Questions and camp collect at most one input per eligible seat. Values and the platform seed remain hidden from every client, including the host, until the phase seals. At seal, the public record uses stable seat order, explicitly marks missing input `absent`, and reveals the seed. The platform never supplies a missing gameplay default. Play records one `done` or `dead` result and a 64-hex deterministic checksum per eligible seat. Different submitted checksums set `desync`; the checksum is diagnostic and provides no anti-cheat claim.

The eligible game roster freezes on start. Each timed phase freezes its required connected seats when it opens. A disconnected required player remains required for a 15-second server presence grace and then stops blocking early completion; an empty connected set never causes a vacuous early transition. Rejoining after a phase seals does not edit its record. The persisted deadline always advances the phase even if the host page is closed, the host avatar is dead, or no client remains connected. Lobby start, moderation and final presentation remain host actions; timed phase advancement does not.

`createPhaseRoomClient()` exposes `createRoom({displayName})`, `joinRoom(code,{displayName,role})`, `resumeRoom(code)` and `dispose()`. A returned room exposes its projected `snapshot` plus `setLobbyMetadata(metadata)`, `start()`, `submitInput(input)`, `submitResult({status,checksum})`, `moderate(changes)`, `leave()` and host-only `close()`. `role` is `host`, `player` or `spectator`; the host occupies player seat zero, so the 16-player limit includes the host, while up to eight spectators are separate and read-only. The room-wide `sequence` advances for every accepted persistent mutation. Public `version` advances for lobby metadata and game-state transitions; joins, presence, moderation and hidden submissions do not change it.

Every mutating member command has a monotonically increasing `sequence` and stable 32-hex `operationId`. Retry an uncertain action with the same operation ID and identical content. The SDK retains the original command envelope so a socket update received after an HTTP response loss cannot change the retry's sequence. A reused operation ID with different content conflicts. Credentials are generated and durably saved by the trusted parent before creation/join reaches the service; if guarded browser storage is unavailable, the room command fails before creating a stranded seat while the rest of preview remains usable. A repeated create with the same pending identity recovers that room. Close the existing room before intentionally creating a fresh one; a different identity cannot silently replace a recoverable pending room. Credentials never enter the opaque frame, URL, manifest, logs or analytics.

Readable invitations use the compact share service and resolve only while the exact edition room remains live. The parent forwards the resulting `#room=ABC234` value; the app must parse one exact code, show the join screen and require an explicit Join action before publishing a display label. Opening an invitation never grants a host/player credential. A room's hard lifetime is six hours and is never renewed by reads, links or reconnects.

At the hard expiry, the alarm closes the room, releases its directory reservation, and calls Durable Object `deleteAll()` so the room's SQLite rows, tables, indexes and storage metadata are reclaimed. A later request to the same object name recreates an empty schema and cannot recover the expired room.

Local platform verification uses the actual Wrangler Worker, SQLite Durable Objects and two headless Chrome pages:

```sh
PARTY_V2_WORKER=1 node --test scripts/party-v2-worker.test.mjs

# Optional real-clock proof: wait through the actual 80-second questions alarm.
PARTY_V2_WORKER=1 PARTY_V2_ALARM=1 node --test scripts/party-v2-worker.test.mjs
```

Set `WRANGLER_BIN` or `PLAYWRIGHT_MODULE` only when those already-installed tools live elsewhere. The harness uses isolated temporary local persistence and signed test-only internal contexts to send Worker HTTP commands from two Chrome pages; SDK/parent-bridge boundary tests run separately. It calls no hosted service and closes its test room. With `PARTY_V2_ALARM=1`, a second room starts with host and player connected, disconnects the host, sends no advancing request, and requires the real persisted 80-second alarm to push `play` with both inputs explicitly absent to the connected player. Core/mock-clock tests separately cover all deadlines, stale alarms, disconnect grace, three rounds, absent inputs, spectator denial, checksum disagreement and public-version versus mutation-sequence behavior. This evidence is local platform proof, not a deployed-service, physical-device, cross-browser or operating-cost result.

## Recovery, rematches and departures (SDK 1.5.0)

See [Room dynamics](docs/premium/ROOM_DYNAMICS.md) for the complete contract and engineering invariants. `client.listResumableRooms()` discovers only this browser’s live credentials for the exact edition. `room.detach()` preserves a seat while stopping transport; `room.leave()` revokes it and immediately stops its input from blocking a phase. Roster status distinguishes `connected`, `disconnected` and `left`. Host-only `room.restart()` from final returns the same room to lobby with incremented `gameIndex`, empty game records and fresh seeds on the next start. Command sequences remain monotonic, per-game budgets reset, and the six-hour room expiry never extends. At most 20 games may run in one room. Use the new room module with the unchanged player-services runtime 1.3.0. These additions do not alter party/1.

## Premium party/1 creation admission

When the party/1 premium-admission release is deployed, the trusted gateway checks an active premium family grant on every create request and supplies a short-lived, exact-revision signed admission context. Client headers cannot choose a resource tier. Entitled party/1 families get 100 creations in a rolling 24 hours, 20 active reservations per family across revisions, and 100 creations per hashed network in a rolling 24 hours across premium party/1 families. Standard apps retain the 100-room lifetime pilot and five/network/calendar-day caps. These two protocols have separate admission counters; this is not a combined party/1 plus party/2 budget.

The party/1 SDK, two-hour room lifetime, player limits and command/event limits remain unchanged. An authenticated close releases concurrent capacity but does not refund a daily creation. Expired rooms also continue to count for the full daily window. Failed initialization can conservatively hold a reservation until its two-hour expiry; reuse the exact create operation for a retry. Existing pilot rooms stay in the pilot ledger when their family gains premium. No model spending, browser permission, creator quota or feed promotion is granted by room admission. Verify a release receipt before treating this source contract as live.

An upload/1 family may later receive premium and submit an upload/2 revision: retain creator and slug, use the existing family, and set exact parentSubmissionId. A grant for a different family is rejected.
