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

visibilityWho sees itExtra options
'all' (default)every clientnone
'role'only the roles listed in rolesroles
'owner'only the client that owns each recordnone
'spatial-grid'only clients whose anchor is within radius cellsgrid (required)
'server'nobody: no client receives its datanone

'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:

  1. Give it its own collection and scope that collection.
  2. 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:

SourceExample
A JWT role claimYour auth server mints { role: 'host' } for the account running the stream
Room codectx.room.setRole(clientId, 'host') in onJoin or an RPC
A resumed sessionA 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 no roles hides the collection from everyone. This is valid but almost always a mistake, so irtio deploy warns 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 admin role 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 roles must be declared in the schema’s roles list. 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 owner is 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, and roles is rejected on an owner collection at deploy time. room.setRole moves 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: true and '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 keyTypeMeaning
xfield namenumeric scalar field holding the world x coordinate
yfield namenumeric scalar field holding the world y coordinate
cellnumberworld units per cell, positive and finite
radiusintegerhow many cells out from the anchor’s cell, zero or more
wideRolesrole namesroles 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 kind field.
  • A client with no anchor sees no records from that collection. Its join still succeeds and every other collection arrives normally. A spectator outside wideRoles gets an empty collection.
  • A client always sees its own anchor, even at the edge of the world.
  • wideRoles is 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 in grid.wideRoles, and let onJoin skip 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.

  • Schema for the collection options visibility sits alongside.
  • Ownership for who may write what, a separate question from who may see it.