09Docs
Realtime & live
Every collection is live. Beyond subscribe, Bounded has a native server-authoritative realtime runtime for any room, multiplayer games, Figma-style editors, whiteboards, live dashboards. You upload three pure functions; Bounded runs the tick, checkpoints state through your invariants, and fans per-client views out live.
Live subscriptions
subscribe(path, opts) (from @bounded-sh/client) streams a single document or a filtered collection and calls onData on every change, returning an unsubscribe function. Read rules are enforced per delivered document, so a subscription never delivers a doc the caller can’t read.
import { subscribe } from "@bounded-sh/client";
// One document or a filtered collection, onData fires on every change.
const stop = await subscribe("rooms/r1", {
onData: (room) => render(room),
onError: (e) => console.error(e),
});
// later:
await stop();SubscribeOptions: filter, prompt, shape, limit, cursor, onData, onError, appId. Filters and paging match get (see the SDK reference).
Native live runtime, three pure functions
A complete native live room is four artifacts and no infrastructure: a module of three pure functions, a session.live policy block, a one-line upload, and the SDK client. Bounded loads the module into an isolated facet inside the per-room Durable Object and runs the tick server-authoritatively (~30Hz).
// pong.live.ts, exactly three pure functions, nothing else.
// 1. init(seed) -> initial state (optional)
// 2. tick(state, intents, dt) -> next state (required; server-authoritative)
// 3. views(state) -> { [userId]: view } (optional; per-client visibility)
//
// intents = [{ userId, intent }, ...] received since the last tick;
// Bounded orders them, you decide what they mean.init(seed), optional. The initial state when the room starts.tick(state, intents, dtMs), required, server-authoritative. Returns the next state; the only thing that advances the room.views(state), optional. Maps each universal user id to what that client may see; Bounded fans each entry out to that user’s view collection.
For a game these are the game loop; for a Figma-style editor they are the canonical document plus each editor’s cursor/selection projection; for a dashboard they are the ingest reducer plus each viewer’s permitted slice. Same three functions, any realtime app.
{
"rooms/$roomId": {
"tier": "checkpointed",
"fields": { "status": "String", "tick": "UInt" },
"rules": { "read": "@user.id != null", "create": "@user.id != null", "update": "false", "delete": "false" },
"session": {
"live": { "module": "pong", "everyMs": 33, "maxLifetimeSec": 1800, "snapshotEveryTicks": 30 },
"intentRule": "@user.id != null"
}
},
"rooms/$roomId/view/$userId": {
"tier": "ephemeral",
"fields": { "stateJson": "String" },
"rules": { "read": "$userId == @user.id", "create": "false", "update": "false", "delete": "false" }
}
}| Field | Rule | Meaning |
|---|---|---|
module | required | Bare identifier that resolves to your uploaded source in the code registry. |
everyMs | required, 20–60000 | Native tick cadence (ms). ~33 ≈ 30Hz. |
maxLifetimeSec | required, 1–86400 | Hard lifetime cap; the room is torn down at this age. |
snapshotEveryTicks | optional, 1–600 | Snapshot in-memory state to facet SQLite every N ticks; bounds post-eviction loss. |
secrets | not supported | Live modules cannot receive secrets. Call a Bounded Function or backend runtime component for secret-backed work. |
session.intentRule | explicit sibling rule | Controls which principals may send intents. If absent, intents are denied. |
Upload & drive
Deploy uploads the source to the R2 code registry, the etag is the version, and no worker is redeployed (same model as Functions). A fresh facet picks up the new source on the next room start.
bounded live deploy pong.live.ts --app-id <id> # upload source; prints the version (etag)The worker routes are already live on the room DO. Clients address a room by path; the worker derives the room id and sets X-Room-Id itself, clients never set it.
| Route | Addressed by | Auth | Returns |
|---|---|---|---|
GET /live/status | ?path=<collection>/<roomId> | none | { available, started, running, tick, module, etag, stopReason, generation } |
POST /live/intent | body.path | required | { ok: true } |
import { live } from "@bounded-sh/client";
const roomId = "r1";
const path = `rooms/${roomId}`; // session collection + room id
// 1. Live state, subscribe to YOUR view only. live.subscribeView builds
// `<roomPath>/view/<myUserId>` and routes the socket to the room DO.
const stop = await live.subscribeView(path, {
onData: (view) => render(view),
});
// 2. Send an intent, address the room BY PATH; never set X-Room-Id.
await live.intent(path, { type: "move", dir: -1 }); // -> { ok: true }
// 3. Lobby / liveness
const status = await live.status(path); // { running, stopReason, tick, etag, generation }Fog-of-war & anti-cheat (structural)
The per-client view (rooms/$roomId/view/$userId) is ephemeral with read rule $userId == @user.id. The keys of the map returned by views(state) are universal user ids; Bounded fans each key out to that user’s view collection, and the read rule guarantees a client can only ever subscribe to its own view. There is no delivery path for anyone else’s. This is the structural cure for maphacks/wallhacks and for leaking private layers in a collaborative editor, hidden information is never written to a view it doesn’t belong to.
The three roles stay clean:
- intents are the only client write path, and they are server-ordered by Bounded.
- tick is server-authoritative, clients never write state, only intents.
- views are read-only projections, a client reads its view, never writes it.
Your declared invariants (e.g. a bound on score) are enforced on the checkpointed authoritative state every checkpoint, so even the room’s own tick cannot checkpoint an illegal score, even though the 30Hz tick runs untrusted user code.