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
| Room | Current automatic behavior |
|---|---|
| 1–8 members | Full mesh |
9–50 members with payload: 'latest-state' | Sparse four-neighbor graph |
| Reliable/global traffic through 8 members | Automatic full mesh |
| Reliable/global traffic from 9–50 members | Unavailable 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.
| Runtime | Public API | Credential rule |
|---|---|---|
| Browser JavaScript | openrtc/service-node | Obtain a short-lived grant from your backend; never ship an sk_* key |
| Node.js | openrtc/node | Keep the server key in the Node process; the packaged Rust host owns networking |
| Native Rust | openrtc::native::ControlPlane | Use 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.