Visibility
Every client receives every collection in the schema by default. The visibility option
changes that per collection: a role-scoped collection reaches only the roles you name, an
owner-scoped collection reaches only the client that owns each record, and a spatial collection
reaches only clients whose position is close enough to see it.
Use it when some clients must not see some state, or when a room holds far more entities than any one player needs.
// irtio/schema.ts
// Only the host screen receives this collection.
answers: entity(
{ choice: u8, correct: bool },
{ serverOwned: true, visibility: 'role', roles: ['host'] },
), Visibility is declared in the schema, not computed in your handlers. There is no per-client
filtering hook, so what a client sees depends only on the collection’s mode and the client: its
role for 'role', which records it owns for 'owner', and its anchor’s position for 'spatial-grid'.
The five modes
visibility | Who sees it | Extra options |
|---|---|---|
'all' (default) | every client | none |
'role' | only the roles listed in roles | roles |
'owner' | only the client that owns each record | none |
'spatial-grid' | only clients whose anchor is within radius cells | grid (required) |
'server' | nobody: no client receives its data | none |
'owner' and 'spatial-grid' work on entities only, and they filter records rather than whole
collections. A singleton can be 'all', 'role' or 'server'.
When to restrict a view
Restrict a view for three reasons.
Secrets. A client that receives a value has it. Hiding a card in the UI does nothing when the answer sits in the browser’s memory. A value a player must not have is a value you must not send.
Fog of war. In a shooter or a strategy game, seeing across the map is cheating. The information is not secret forever, only secret from here.
Bandwidth. An arena with five thousand entities cannot send all of them to everyone twenty times a second. In a 200-player arena room, a filtered player receives about 2.7 kB/s and a full-view spectator receives about 68 kB/s.
Per collection, not per field
visibility is an option on entity() and singleton() and applies to the whole collection.
You cannot show a role three fields of a singleton and hide the fourth.
To hide a secret you have two options:
- Give it its own collection and scope that collection.
- Do not write the value into state until it should be visible. A quiz room can keep the correct answer in a singleton everyone sees, and write it only when the round reaches its reveal phase.
Option 2 is often simpler, and there is nothing to leak. Use it when the secret has a moment where it stops being one.
Server-private collections
visibility: 'server' keeps a collection’s data on the server. No role sees a row, no client
may write it, and no value is ever encoded into a snapshot or a delta. Use it for the room’s
own bookkeeping: a shuffled deck, a spawn table, per-player cooldowns you do not want on the
wire.
// irtio/schema.ts
// The room's own state. No row here reaches a client.
deck: entity({ card: u8 }, { visibility: 'server' }), It takes no roles. Your handlers read and write it like any other collection, and it counts
against the room’s memory rather than its bandwidth.
The data is private; the definition is not. The schema module is shared by the server and the client, so the collection’s name and its field names are part of the schema every client holds, and they travel on the wire in the schema frame an additive migrate sends. Treat the names as public and keep the secret in the values.
At runtime room.state.<name> exists on the client as an always-empty collection object. The
TypeScript view omits the property, so ordinary code cannot reach it, but a test that casts
past the types should assert size === 0 rather than expecting undefined.
Role views
Roles are declared once on the schema and granted, not chosen. A client joins with the schema’s first role unless something the server trusts says otherwise:
| Source | Example |
|---|---|
A JWT role claim | Your auth server mints { role: 'host' } for the account running the stream |
| Room code | ctx.room.setRole(clientId, 'host') in onJoin or an RPC |
| A resumed session | A reconnecting client keeps the role it held |
The role a client passes to joinRoom is a request. Without a JWT claim it is ignored, the room
logs a warning, and the client gets the first role. A client cannot promote itself into a
role-gated collection by asking.
Grant a role from room code
Decide in onJoin, from something the client cannot forge, then set the role:
// irtio/room.ts
onJoin(state, ctx) {
// ctx.auth is 'key' for an anonymous join, 'jwt' or 'assertion' for a verified identity.
if (ctx.auth !== 'key' && isHostAccount(ctx.playerId)) {
ctx.room.setRole(ctx.clientId, 'host');
}
}, A setRole inside onJoin lands before the client’s first frame: its WELCOME snapshot is
built from the role the join settled on, and the client is told nothing about the collections
it did not get.
Let clients pick
Some games have nothing to protect behind a role: a party game where any phone may be the TV, or
a spectator mode that hides nothing. Set clientRoles: true to honour the role a client asks for:
// irtio/schema.ts
export const schema = defineSchema(defs, {
roles: ['controller', 'host'] as const,
clientRoles: true,
}); With it on, anyone can join with any role your schema declares. Do not gate private data behind a
role a client can pick. State that must never reach a client at all belongs in a 'server' collection
rather than behind a role.
A collection with visibility: 'role' names the roles that may see it. Every other role never
learns it exists. Adds and removes are filtered along with values, so a hidden record’s arrival
is not announced.
Schema
// irtio/schema.ts
import { bool, defineSchema, entity, enumOf, singleton, str, u8, u16 } from '@irtio/schema';
import { rpc } from './rpc.js';
export const schema = defineSchema(
{
match: singleton(
{ phase: enumOf('lobby', 'question', 'results'), question: u8 },
{ serverOwned: true },
),
scores: entity({ name: str(24), points: u16 }, { serverOwned: true }),
// Only the shared host screen sees who answered what. A controller phone gets the match
// state and the scoreboard, and never receives a single `answers` record.
answers: entity(
{ choice: u8, correct: bool },
{ serverOwned: true, visibility: 'role', roles: ['host'] },
),
},
{
project: 'p_7f3a1c9e40b2d518',
roles: ['controller', 'host'] as const,
rpc,
},
); Room file
The room file does not mention visibility. It writes state normally.
// irtio/room.ts
import { defineRoom } from '@irtio/server';
import { schema } from './schema.js';
const CORRECT_CHOICE = 2;
export default defineRoom(schema, {
mode: 'event',
onJoin(state, ctx) {
if (ctx.reconnecting) return;
// Everyone joins as a controller. The first client is the shared screen, and the room
// grants it host; no client can ask its way into the answers.
if (!ctx.room.clients.some((c) => c.role === 'host')) {
ctx.room.setRole(ctx.clientId, 'host');
return;
}
state.scores.add(ctx.clientId, { name: ctx.name || 'anon', points: 0 });
},
onLeave(state, ctx) {
state.scores.remove(ctx.clientId);
state.answers.remove(ctx.clientId);
},
rpc: {
start(state, _params, ctx) {
if (ctx.role !== 'host') ctx.deny('host only');
for (const [id] of state.answers) state.answers.remove(id);
state.match.phase = 'question';
},
answer(state, { choice }, ctx) {
if (state.match.phase !== 'question') ctx.deny('not accepting answers');
if (state.answers.has(ctx.clientId)) ctx.deny('already answered');
const correct = choice === CORRECT_CHOICE;
// A host-only collection, so no controller sees another player's choice.
state.answers.add(ctx.clientId, { choice, correct });
if (correct) {
const row = state.scores.get(ctx.clientId);
if (row) row.points += 10;
}
},
},
}); What each client receives
// main.ts on the shared screen
import { joinRoom } from '@irtio/client';
import { schema } from './irtio/schema.js';
// Opened first, so the room grants it host. The literal types room.state as the host's view.
const room = await joinRoom(schema, { role: 'host' });
room.state.answers.size; // 4, one per controller that has answered
room.state.answers.get('c_9f2a'); // { choice: 2, correct: true } // main.ts on a controller phone
const room = await joinRoom(schema, { role: 'controller' });
room.state.match.phase; // 'question'
room.state.scores.size; // 4
room.state.answers; // compile error: no such property on this role's state A literal role passed to joinRoom narrows room.state to that role’s collections, so
reaching for a hidden collection is a type error rather than an empty object at runtime.
Role rules
visibility: 'role'with noroleshides the collection from everyone. This is valid but almost always a mistake, soirtio deploywarns instead of shipping it silently.- A role no client joins as is a useful trick. Park server-private working state, such as
a tile bag or seat keys, behind an
adminrole the room never hands out. The state persists like any other state and syncs to nobody. Players learn their own slice from an RPC return, which is private to its caller. - A role named in
rolesmust be declared in the schema’sroleslist. A typo is a startup error that names the collection and the role. room.setRole(clientId, role)sends a catch-up. A newly visible entity collection arrives as adds, a collection that stopped being visible arrives as removes, and a newly visible singleton arrives as a whole-record update.- Losing visibility does not erase what a client already has. A singleton that stops being visible keeps its last values on the client and stops updating. Set a client’s role before you write a secret it must not have.
Owner views
visibility: 'owner' sends each record to the one client that owns it, and to nobody else. It is
the mode for per-player hidden state: a hand of cards, a secret objective, a queued order.
Ownership here is the same ownership perPlayer, requestOwnership and the owner option already set on a record. You do not declare roles and you do not move
records between collections; you set the owner, and the view follows.
// irtio/schema.ts
hand: entity({ card: str(16), score: u16 }, { visibility: 'owner' }), // irtio/room.ts
onJoin(state, ctx) {
state.hand.add(ctx.clientId, { card: draw(), score: 0 }, { owner: ctx.clientId });
}, Each client’s state.hand contains only its own record. The adds and removes are scoped the same
way, so a client never learns another player’s record exists.
Ownership rules
- A record owned by the server reaches no client. Ownership defaults to the server, so a
record you add without an
owneris hidden from everyone until you give it one. A face-down draw pile is a record nobody owns. - Changing the owner moves the record. The old owner receives a remove and the new owner
receives an add, in the next flush. Pass a record to another player by calling
setOwner. - A role change does not change what an owner sees.
'owner'is scoped by ownership alone, androlesis rejected on an owner collection at deploy time.room.setRolemoves nothing. - Clients may not write records they do not own. A write naming somebody else’s record is refused in silence, the same way an out-of-view spatial write is.
serverOwned: trueand'owner'do not go together. Every record would stay server-owned, so no client would ever see one; the deploy check warns about it.
Spatial views
visibility: 'spatial-grid' divides the world into square cells and sends each client the
records in the block of cells around its own. Use it for large worlds where most of the state
is irrelevant to most players.
Schema
// irtio/schema.ts
import { defineSchema, entity, enumOf, f32, str } from '@irtio/schema';
export const WORLD = 4096;
export const CELL = 128;
export const RADIUS_CELLS = 2;
export const MAX_STEP = 32;
export const schema = defineSchema(
{
// Players and pellets share one collection, because the anchor must live in the same
// collection it filters. A `kind` field tells them apart.
objects: entity(
{ kind: enumOf('player', 'pellet'), x: f32, y: f32, radius: f32, color: str(12) },
{
visibility: 'spatial-grid',
grid: {
x: 'x',
y: 'y',
cell: CELL,
radius: RADIUS_CELLS,
wideRoles: ['spectator'],
},
},
),
},
{ project: 'p_a4e4a0cafe000001', roles: ['player', 'spectator'] as const },
); grid key | Type | Meaning |
|---|---|---|
x | field name | numeric scalar field holding the world x coordinate |
y | field name | numeric scalar field holding the world y coordinate |
cell | number | world units per cell, positive and finite |
radius | integer | how many cells out from the anchor’s cell, zero or more |
wideRoles | role names | roles that see the whole collection with no anchor |
Each key is checked when the room starts, and a bad value is an error that names the
collection. That covers a grid on a non-spatial collection, a spatial-grid singleton, a
non-numeric coordinate field, a zero or negative cell, a fractional radius, and a wideRoles entry that is not a declared role.
Room file
The anchor convention lives in onJoin. The record whose id equals the client id is that
client’s anchor.
// irtio/room.ts
import { defineRoom } from '@irtio/server';
import { CELL, MAX_STEP, WORLD, schema } from './schema.js';
/** FNV-1a, so a spawn point is a pure function of the client id. */
function hash(id: string, basis: number): number {
let h = basis;
for (let i = 0; i < id.length; i++) {
h ^= id.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h;
}
const axis = (id: string, basis: number) => (hash(id, basis) / 0x100000000) * (WORLD - 1);
export default defineRoom(schema, {
mode: 'tick',
tickRate: 20,
maxClients: 240,
onCreate(state) {
for (let cy = 0; cy < WORLD / CELL; cy += 2) {
for (let cx = 0; cx < WORLD / CELL; cx += 2) {
state.objects.add(`pellet:${cy}:${cx}`, {
kind: 'pellet',
x: cx * CELL + CELL / 2,
y: cy * CELL + CELL / 2,
radius: 4,
color: '#7dd3fc',
});
}
}
},
onJoin(state, ctx) {
// A spectator is in `wideRoles` and needs no anchor, so it gets no record.
if (ctx.role !== 'player' || ctx.reconnecting) return;
// The id must be ctx.clientId. That is what makes this record the client's anchor.
// Spread by client id, not by ctx.tick: a hundred clients can join inside one tick, and a
// tick-derived spawn puts all of them on the same pixel and in the same cell.
state.objects.add(
ctx.clientId,
{
kind: 'player',
x: axis(ctx.clientId, 0x811c9dc5),
y: axis(ctx.clientId, 0x01234567),
radius: 13,
color: '#4dabf7',
},
{ owner: ctx.clientId },
);
},
onLeave(state, ctx) {
state.objects.remove(ctx.clientId);
},
validate: {
objects(prev, next, ctx) {
if (prev.kind !== 'player' || ctx.clientId === undefined) return prev;
if (Math.hypot(next.x - prev.x, next.y - prev.y) > MAX_STEP) return prev;
return {
...prev,
x: Math.min(WORLD - 1, Math.max(0, next.x)),
y: Math.min(WORLD - 1, Math.max(0, next.y)),
};
},
},
tick() {},
}); What each client receives
// main.ts
import { joinRoom } from '@irtio/client';
import { schema } from './irtio/schema.js';
const room = await joinRoom(schema, { role: 'player' });
const me = room.state.objects[room.me]; // the anchor, always visible
me.x += 4; // an owned write, validated server-side
// About twenty records: this player, any neighbours in range, and the pellets inside the
// 5 x 5 block of cells around the anchor's cell.
room.state.objects.size;
// Records enter and leave as the anchor moves. There is nothing to subscribe to: an entering
// record arrives as an ordinary add, a departing one as an ordinary remove.
for (const id of room.render.objects.ids()) {
const object = room.render.objects.get(id);
// draw it
} // main.ts?role=spectator
const room = await joinRoom(schema, { role: 'spectator' });
room.state.objects.size; // every pellet and every player in the arena
room.me; // a client id with no record, because spectators do not spawn one Membership rules
- The anchor is the record in the same collection whose id equals the client id. You
cannot anchor one collection against another. To filter players and items together, put them
in one collection with a
kindfield. - A client with no anchor sees no records from that collection. Its join still succeeds
and every other collection arrives normally. A spectator outside
wideRolesgets an empty collection. - A client always sees its own anchor, even at the edge of the world.
wideRolesis the escape hatch. A client in one of those roles skips the grid and gets the whole collection, with no anchor and no record of its own. That is what the option is for: a spectator, a dead player still watching the round, a caster’s overview, a debug view. Give them a role, list it ingrid.wideRoles, and letonJoinskip adding a record for them. The cost is the full collection every flush, so keep the role for the few clients that need it.- The cell is
floor(position / cell). Negative coordinates bucket correctly, so a world centred on the origin works. - The neighbourhood is a square of
(2 * radius + 1)cells on each axis. There is no circular post-filter, so a record at the diagonal corner is visible while a record the same true distance away on an axis may not be. - There is no hysteresis. A record sitting on a cell boundary while the anchor jitters enters and leaves on every crossing, and each entry costs a full record on the wire.
- Crossings arrive as adds and removes, delayed by the same render buffer as everything else. There is no cross-boundary extrapolation and no fade. Fade in your rendering code.
- A flush that changes nothing sends nothing. An anchor moving inside an unchanged membership set costs no bytes for that collection.
Choosing a spatial view
Spatial filtering sends whole records in nearby grid cells. Use game logic for line of sight or custom distance tests, and separate collections for information with different access rules.
When filtering stops paying off
Filtering trades bytes for churn. A record entering a client’s view costs a full record, while a record that stays visible costs only its changed fields. At five thousand entities with every client moving fast, boundary crossings alone pushed peak bytes per client above an unfiltered view. Density plus speed erases the advantage, not entity count on its own.
If you measure that, lower radius first, then raise cell. The index itself is cheap: at
five thousand entities, rebuilding the buckets and running every client’s query costs about
1.6 ms per tick against a 50 ms tick.
Choosing a mode
Use 'role' when a category of client should not have a category of information: a host screen
against player phones, a referee against competitors, or server-private state behind a role
nobody joins as.
Use 'owner' when each player holds state only that player may see, and the record already has
an owner. It is the mode for hidden information, and unlike 'spatial-grid' there is no anchor
that can walk close enough to see somebody else’s.
Use 'spatial-grid' when the room holds more entities than any one player needs and “near” is
a real concept in your game. It starts paying off at roughly a few hundred entities.
Use 'all' when the room is small. Sixteen players and one board is cheaper and easier to
debug unfiltered. Do not use 'spatial-grid' to hide a secret: it is a bandwidth tool, and an
anchor that walks close enough sees everything.
Test for leaks
Visibility bugs are quiet, because a leak looks like a client that works. The test harness compares each client’s frames against the authoritative state and fails on anything a client received but should not have, for role, owner and spatial collections alike.
// irtio/room.test.ts
import '@irtio/testing/matchers'; // registers the matcher on Vitest's `expect`
import { testRoom } from '@irtio/testing';
import room from './room.js';
const t = await testRoom(room);
await t.join(2, { role: 'player' });
await t.tick(10);
expect(t).toHaveNoVisibilityLeaks(); Keep that assertion in your room tests rather than reading frames by eye. A failure names the viewer, the collection and id it should not have seen, and — depending on the mode — the actual owner of the record, or both cells and the radius.