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:

  1. Give it its own collection and scope that collection. This is the direct answer, and it is what the worked examples below do.
  2. Do not write the value into state until it should be visible. examples/party-quiz keeps 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

visibilityWho sees the collectionExtra options
'all' (default)every clientnone
'role'only the roles listed in rolesroles
'spatial-grid'only clients whose anchor is within radius cellsgrid (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 no roles hides the collection from everyone. irtio deploy warns 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-post puts the tile bag, the racks and the seat keys in one singleton scoped to an admin role 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 roles must be declared in the schema’s roles list. 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 keyTypeMeaning
xfield namea numeric scalar field holding the world x coordinate
yfield namea numeric 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

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 kind field, 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 wideRoles gets 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 radius cells 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.