Adaptive large rooms

Let OpenRTC choose a safe, credit-aware room shape as membership changes

OpenRTC separates two decisions:

  • the room architecture decides which peers exchange application data;
  • the transport selector decides whether each selected connection uses direct Iroh QUIC, WebRTC, MoQ, or an allowed relay path.

Your app normally chooses neither. Join with architecture: 'auto'—the default—and keep using the same room, channel, stream, and media APIs.

const room = await rtc.rooms.join('world-42', {
  architecture: 'auto',
  payload: 'latest-state',
});

const stop = room.onArchitectureChanged((snapshot) => {
  console.log(snapshot.effective, snapshot.reason, snapshot.phase);
});

await room.channel<{ x: number; y: number; z: number }>('position').send({
  x: 12,
  y: 4,
  z: -8,
});

stop();
await room.leave();

What auto does today

RoomCurrent automatic behavior
1–8 membersFull mesh
9–50 members with payload: 'latest-state'Sparse four-neighbor graph
Reliable/global traffic through 8 membersAutomatic full mesh
Reliable/global traffic from 9–50 membersUnavailable in 2.5; use fixed mesh only through 16 or a developer authority

This release admits at most 50 members to a room or space. Member 51 is rejected before another topology or hosted fan-out allocation is created. Larger logical rooms and sharded cells remain long-term work rather than a hidden preview path.

payload defaults to reliable. The value is a room-wide contract, just like architecture; clients cannot quietly disagree about delivery semantics. Managed fan-out mechanics exist behind internal gates, but managed rooms are not a production-ready 2.5 capability. Fixed managed, automatic reliable rooms above eight members, and any room above 50 fail with the stable, non-retryable room-architecture-unavailable code. Fixed mesh remains available through its reviewed 16-member limit. Developer authority requires a healthy registered service. There is no plaintext or silently more expensive fallback.

Internal managed-room tests keep the encrypted paging and fan-out mechanisms exercised for a later release. They are mechanism evidence, not permission for an application to select managed fan-out in 2.5. Mesh and sparse rooms keep live peer membership changes because those changes alter their connection graph.

The policy checks the room every 30 seconds. A normal change needs two favorable checks and remains stable for at least five minutes after switching. This avoids changing the graph for a short membership spike. Safety, capacity, or budget failures may stop new work immediately.

Inspect the decision

const current = room.architecture();

console.log({
  requested: current.requested, // auto, unless you chose a fixed mode
  effective: current.effective, // mesh, sparse, managed, or authority
  phase: current.phase,         // preparing or settled
  reason: current.reason,
  heldCreditsUsd: current.heldCreditsUsd,
  quoteExpiresAtMs: current.quoteExpiresAtMs,
});

The snapshot contains customer credits only. It does not expose provider prices, margins, reserves, or internal capacity limits.

Estimate before joining

const quote = rtc.usage.estimateRoom({
  architecture: 'auto',
  maxMembers: 50,
  durationSeconds: 60 * 60,
  delivery: 'latest-state',
  maxPayloadBytes: 512,
  maxMessagesPerSecond: 20,
});

console.log(quote.creditsUsd, quote.effectiveArchitecture, quote.assumptions);

The quote is deliberately conservative and expires. OpenRTC releases unused reservations after settlement. Hosted or relayed delivery starts at $0.10 of customer credit per decimal GB; coordination and other operations remain separate credit dimensions.

Fixed modes

Use a fixed mode only when the whole room requires it:

const room = await rtc.rooms.join('eight-player-match', {
  architecture: 'mesh',
  payload: 'reliable',
  maxPeers: 8,
});

A fixed mode is a room-wide contract, not a preference for one client. Conflicting clients fail with room-architecture-conflict. Fixed managed fails with room-architecture-unavailable in 2.5. Fixed mesh is limited to 16 members. Fixed sparse is limited to 50 members and supports ephemeral/latest-state channel traffic; unsupported reliable, targeted, or state semantics fail instead of being weakened.

advancedFanout: true is deprecated. During this compatibility release it means architecture: 'sparse' with latest-state delivery; it will be removed in the next major version.

Run a developer authority

Preview: Browser and Node authority APIs are source-complete but are not production release evidence yet. Keep them behind an application feature gate until the bundled Node host and source-bound browser, Node, and native Rust authority E2E lanes pass.

An authority is a trusted OpenRTC client that runs your game or application logic. It is not limited to native Rust: OpenRTC provides public APIs for browser JavaScript, Node.js, and native Rust. Players connect to that client instead of trusting one another. OpenRTC still owns discovery, admission, reconnects, transport choice, and relay fallback; your code owns simulation, validation, and the data it sends.

RuntimePublic APICredential rule
Browser JavaScriptopenrtc/service-nodeObtain a short-lived grant from your backend; never ship an sk_* key
Node.jsopenrtc/nodeKeep the server key in the Node process; the packaged Rust host owns networking
Native Rustopenrtc::native::ControlPlaneUse the Rust API directly; JavaScript is not required

Browser JavaScript uses the browser-safe service-node entrypoint. The grant callback must call your authenticated backend and return one short-lived authority grant. Never put an sk_* key in browser code or hide one inside the callback.

import { OpenRTC } from 'openrtc';
import { ServiceNode } from 'openrtc/service-node';

const rtc = OpenRTC({ apiKey: import.meta.env.VITE_OPENRTC_API_KEY });
const service = new ServiceNode({
  client: rtc,
  apiKey: import.meta.env.VITE_OPENRTC_API_KEY,
  serviceId: 'world-simulation',
  generation: 1,
  shardIds: ['zone-a'],
  deviceId: await rtc.devices.localId(),
  // Keep these private keys non-extractable and persistent for this service.
  deviceSigner,
  assignmentSigner,
  grantIssuer: (request) => fetch('/api/openrtc/authority-grant', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(request),
  }).then((response) => response.json()),
});

service.onConnection(({ connection }) => {
  console.log('player connected', connection.id);
});

const world = await service.listen('world-42');
await world.publishAssignment({
  revision: 1,
  subjectDeviceId: 'player-7',
  shardId: 'zone-a',
  relevantEntityIds: ['npc-1', 'npc-2'],
});

Node.js has its own WebSocket-server-like API. It starts the packaged headless OpenRTC host and does not require Tauri, a browser runtime, or an application-supplied IPC bridge. The package fails clearly if the signed host for the current platform is missing; it never silently switches to browser WASM or another lifecycle owner.

import { ServiceNode } from 'openrtc/node';

const service = new ServiceNode({
  apiKey: process.env.OPENRTC_API_KEY!,
  secretKey: process.env.OPENRTC_SECRET_KEY!,
  serviceId: 'world-simulation',
  generation: 1,
  shardIds: ['zone-a'],
  deviceId: 'service-replica-1',
  stateDirectory: '/var/lib/world-simulation/openrtc',
});

service.onConnection(({ connection }) => {
  connection.onMessage((message) => updateSimulation(connection.peerId, message));
});

const world = await service.listen('world-42');
await world.publishAssignment({
  revision: 1,
  subjectDeviceId: 'player-7',
  shardId: 'zone-a',
  relevantEntityIds: ['npc-1', 'npc-2'],
});

For the bounded service preview, maxPeers counts all room members, including the service. Node defaults to eight. You can request 1 through 50, but the app's approved limit and available credits still apply. Requests above 50 are rejected locally. Browser and standalone Rust grant requests can omit the value to use the app's limit. The separate authority rollout and interoperability gates remain required; accepting a number is not a capacity test.

Service denials from the native host retain their public error code and recovery metadata. In Node, both listen() startup failures and command failures expose RTCError fields such as code, scope, operation, retryable, requestId, and any supplied retry timing. Unknown legacy failures remain ordinary errors. Native Rust callers can inspect openrtc::service_errors::ServiceError::from_error(&error) through context wrappers. These fields describe the failure; they do not authorize an application to create a connection or presence retry loop. Structured metadata contains only the documented public fields, never credentials.

Native Rust does not require TypeScript or Node.js:

use openrtc::native::{Features, RoomAuthorityOptions};

let world = control.join_authority_room(
    std::env::var("OPENRTC_SECRET_KEY")?,
    "world-42",
    "service-replica-1",
    "linux",
    RoomAuthorityOptions {
        service_id: "world-simulation".into(),
        generation: 1,
        shard_ids: vec!["zone-a".into()],
        max_peers: Some(50),
        features: Features::default(),
        assignment_signer,
    },
).await?;

world.publish_assignment(
    1,
    "player-7",
    "zone-a",
    vec![],
    vec!["npc-1".into(), "npc-2".into()],
    60_000,
).await?;

Rust clients of a service

In the checkpoint source (not yet the published crate), ordinary Rust clients can use the same room runtime without a server secret or JavaScript. The app must still be approved for the authority preview. Supply your own device signer and keep its private key in your application's private storage.

use openrtc::Client;
use openrtc::native::{ControlPlane, Features, RoomArchitectureMode, RoomOptions};

let control = ControlPlane::anonymous(api_key, device_signer)?;
let room = control.join_room("world-42", "desktop", RoomOptions {
    architecture: RoomArchitectureMode::Authority,
    features: Features { iroh_relay: true, ..Features::default() },
    ..RoomOptions::default()
}).await?;

let client = room.compose_client(
    Client::builder(api_key.to_string(), Box::new(|| None))?
).await?;
let mut states = client.connection_state_updates();
let mut messages = client.subscribe_native_peer_data();
client.init_iroh(Some(endpoint_private_key.to_vec()), vec![]).await?;
let ticket = client.endpoint_ticket_with_token("v2:room:world-42", 8).await?;
client.update_presence("world-42", "Rust client", &ticket, None).await?;

while let Ok(state) = states.recv().await {
    if state.routable {
        client.send_peer(&state.connection_id, b"hello").await?;
        break;
    }
}
let reply = messages.recv().await?;
// Your application chooses how to decode reply.payload.
room.close().await;

routable means the Rust runtime has admitted the connection and prepared its protected route. The gateway chooses the service; your code does not dial a roster or implement reconnection. Rust sends bytes. A Node service can reply with connection.send(new TextEncoder().encode('hello')); applications can agree on JSON or another format without importing TypeScript internals.

Future large games and live video

Do not send a large live video audience through room gossip. Use BroadcastSession, which can distribute media independently of the room graph.

A future 1,000-member authoritative game needs a developer service that owns game state, validation, simulation, zones, and interest assignments. OpenRTC will connect and secure the selected edges after that mode passes its registration, capacity, and rollout gates. Clients never promote their own priority or zone. That sharded mode is not part of the current 50-member release.

Native Rust

The Rust crate does not need TypeScript:

use openrtc::native::{RoomArchitectureMode, RoomDelivery, RoomOptions};

let room = rtc.join_room(
    "world-42",
    "desktop",
    RoomOptions {
        architecture: RoomArchitectureMode::Auto,
        delivery: RoomDelivery::LatestState,
        ..RoomOptions::default()
    },
).await?;

let mut changes = room.subscribe_room_architecture()?;
if let Some(snapshot) = changes.borrow().clone() {
    println!("room architecture: {:?}", snapshot.effective);
}

room.close().await;

Tauri uses the same Rust-owned snapshot through IPC. The TypeScript layer only renders the decision; it does not start another dialer, topology selector, or retry loop.