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.
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:
{
"rcfVersion": "2.8.0",
"services": ["realtime-room"]
}
Import the published pinned modules; do not copy credentials or bundle a substitute network client:
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:
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:
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:
{
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:
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:
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:
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:
- Host creates; player joins by code; another player joins mid-question and receives current state.
- 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.
- A player's event reaches only the host inbox. Host acknowledges once; refresh/retry cannot double-score.
- Host refresh restores when stored authority exists; blocked storage has a clear fallback. Host disconnect, expiry, quotas and stale-state conflicts are visible.
- Undeclared service, different edition/digest, spoofed frame and unknown code are rejected.
- 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.
- 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:
{
"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:
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.