Visibility
By default every client that joins a room receives every collection in the schema. That is the
right answer for a cursor board and the wrong answer for a card game or an arena. The visibility option on a collection changes it: a role-scoped collection reaches only the roles
you name, and a spatial collection reaches only the clients whose position is near enough to see
it.
Visibility is declared in the schema, not computed in your handlers. The room has no per-client filtering hook, so what a client can see is a static fact about the collection plus that client’s role and position. That is what makes it cheap enough to run every flush.
Why you would restrict a view
Three reasons come up, and they are worth separating because they lead to different modes.
Secrets. A client that receives a value has it. Hiding a card in the UI does nothing when the answer key is sitting in the browser’s memory next to it. If a value must not reach a player, it must not be sent to that player.
Fog of war. A shooter or a strategy game where seeing across the map is the same as cheating. The information is not secret forever, it is secret from here.
Bandwidth. An arena with five thousand entities cannot send all of them to everyone twenty
times a second. In examples/arena with 200 players in one room, a player receives about
2.7 kB/s while a full-view spectator watching the same room receives about 68 kB/s. That ratio is
the whole reason spatial visibility exists.
Visibility is per collection, not per field
visibility is an option on entity() and singleton(), and it applies to the entire
collection. There is no way to show one role three fields of a singleton and hide the fourth.
When a secret needs hiding, you have two options:
- Give it its own collection and scope that collection. This is the direct answer, and it is what the worked examples below do.
- Do not write the value into state until it should be visible.
examples/party-quizkeeps the correct answer in the same singleton everyone can see, and only ever writes it once the round enters its reveal phase. Before that the field holds the previous round’s answer or its zero value, never this round’s.
The second option is often simpler, and it has the advantage that there is nothing to leak. Reach for it when the secret has a moment where it stops being one.
The three modes
visibility | Who sees the collection | Extra options |
|---|---|---|
'all' (default) | every client | none |
'role' | only the roles listed in roles | roles |
'spatial-grid' | only clients whose anchor is within radius cells | grid (required) |
'spatial-grid' is entity-only. A singleton can be 'all' or 'role'.
Role views
Roles are declared once on the schema and chosen by the client at join time, from its HELLO. If
the join carried a JWT with a role claim, that claim overrides whatever role the client asked
for: a client cannot self-promote into a role-gated collection, only a server-asserted token can
put it there. A collection with visibility: 'role' names the roles that may see it, and every other role never learns it exists.
Adds and removes are filtered along with values, so a controller in the example below is not told
that an answers record appeared, only that it did not.
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: ['host', 'controller'] as const,
rpc,
},
); Room file
Nothing in the room file mentions visibility. The room writes state normally and the runtime decides who gets which half.
// 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.role !== 'controller' || ctx.reconnecting) 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') throw new Error('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') throw new Error('not accepting answers');
if (state.answers.has(ctx.clientId)) throw new Error('already answered');
const correct = choice === CORRECT_CHOICE;
// Written to a host-only collection, so no controller ever 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';
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 Passing a literal role to joinRoom narrows room.state to the collections that role can see,
so reaching for a hidden collection is a type error rather than an empty object you debug at
runtime.
Rules worth knowing
visibility: 'role'with noroleshides the collection from everyone.irtio deploywarns about it rather than shipping it silently, because a collection nobody can see is technically valid. It is almost always a mistake.- A role no client ever joins as is a useful trick.
examples/word-postputs the tile bag, the racks and the seat keys in one singleton scoped to anadminrole that the room never hands out. The result is state that persists like any other state and syncs to nobody. Players learn their own rack 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 naming the collection and the role. room.setRole(clientId, role)sends a catch-up. An entity collection that just became visible arrives as adds, one that stopped being visible arrives as removes, and a singleton that just became visible arrives as a whole-record update. A singleton that stops being visible keeps the values the client already had and stops receiving updates, so a role change is how you open a view, not how you take one back. Decide a client’s role before you write a secret it must not have.
Spatial views
visibility: 'spatial-grid' divides the world into square cells and gives each client the records
in the block of cells around its own. It is the mode 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 | a numeric scalar field holding the world x coordinate |
y | field name | a 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 |
Every one of those is checked when the room starts. A grid on a non-spatial collection, a spatial-grid singleton, a coordinate field that is not numeric, a zero or negative cell, a
fractional radius, and a wideRoles entry that is not a declared role are each an error that
names the collection.
Room file
The anchor convention is the part to get right, and it 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 at all.
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 every one 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, whatever neighbours are 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. Nothing special 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 How membership is decided
- The anchor is the record in that same collection whose id equals the client id. There is no
way to anchor one collection against another. If you want players and items filtered together,
they go in one collection with a
kindfield, which is what the example above does. - A client with no anchor sees no records from that collection. Its join still succeeds and
every other collection arrives normally. A spectator that is not in
wideRolesgets an empty collection, which is a common first-run surprise. - A client always sees its own anchor, even at the edge of the world.
- The cell is
floor(position / cell), and negative coordinates bucket correctly. A world centred on the origin works. - The neighbourhood is a square. Membership is every record whose cell is within
radiuscells on both axes, which is a(2 * radius + 1)by(2 * radius + 1)block. There is no circular post-filter, so a record diagonally out at the corner is visible while a record the same true distance away on the axis may not be. - There is no hysteresis. A record that sits exactly on a cell boundary while the anchor jitters enters and leaves on every crossing. 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. A cosmetic fade belongs 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.
What spatial visibility does not do
Spatial visibility is a cell filter and nothing more. It answers one question, “which records sit in the block of cells around this client”, and these are not part of the answer:
- Cross-collection anchors. The anchor is always in the collection being filtered.
- Distance or circular ranges. Membership is decided in whole cells.
- Line of sight, walls, or portals. A wall between two players hides nothing.
- Hysteresis or dwell time. Each crossing takes effect on the flush it happens in.
- Per-field visibility. Here, as everywhere else, the unit is the collection.
- Client-controlled subscriptions. A client cannot ask for a wider view than its role and position give it.
Model any of those in your own game code, on top of the records the filter delivers.
Where it stops paying off
Filtering trades bytes for churn. Every record that enters a client’s view costs a full record, where a record that stays visible costs only its changed fields. In a benchmark at five thousand entities with every client moving fast, boundary crossings alone pushed the peak bytes per client above what an unfiltered view would have cost. Density plus speed is what erases the advantage, not entity count on its own.
If you measure that, the levers are a smaller radius or a larger cell, in that order. The
index itself is cheap: rebuilding the buckets and running every client’s query cost about 1.6 ms
per tick at five thousand entities, against a 50 ms tick at the default rate.
Use this when, and when not
Reach for '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 working state
parked behind a role nobody joins as.
Reach for 'spatial-grid' when the room has more entities than any one player needs, and
“near” is a real concept in your game. It earns its keep from roughly a few hundred entities up.
Do not reach for either when the room is small. A room with sixteen players and one board is
cheaper and much easier to debug with 'all' everywhere. Do not reach for 'spatial-grid' to
hide a secret, either: it is a bandwidth tool that happens to hide things, and an anchor that
walks close enough sees everything.
Checking it holds
Visibility bugs are quiet, because a leak looks like a client that works. The test harness compares each client’s frames against real server authority and fails on anything a client received but should not have, for both role and spatial collections.
// 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 the frames by eye. A failure names the viewer, the collection and id it should not have seen, both cells, and the radius.
The harness is the place for this check, because it holds the real authoritative state and can compare every client against it. That is what lets it judge a spatial view, where the question is not which collection a client received but which ids inside it.
Next steps
- Schema for the collection options visibility sits alongside.
- Ownership for who may write what, which is a separate question from who may see it.
- Presence and lifecycle for roles, joins, and
room.setRole. - Room reference for the full room file surface.
- Limits for the per-room ceilings you are filtering against.