State SyncState View

Per-client State Visibility with StateView

🆕

This feature was introduced in version 0.16 and replaces the previously experimental @filter() and @filterChildren() decorators.

By default, the entire state is visible to all clients. However, you may want to control which parts of the state are visible to each client.

You can do so by:

  1. Assigning a StateView instance to the client
  2. Tag state fields with the .view() field modifier
  3. Manually .add() schema instances to the StateView
  4. Manually .remove() schema instances from the StateView

A StateView instance must be assigned to the client.view.

⚠️

Avoid relying on StateView for large datasets: it is not optimized for that yet. However, it is a great way to filter data per client. Examples: “private fields” per schema instance, “level of detail”, area-based or team-owned data.

Initializing a StateView

MyRoom.ts
import { StateView } from "@colyseus/schema";
// ...
    onJoin(client, options) {
        client.view = new StateView();
        // ...
    }
// ...

How serialization works

  • Each StateView instance is going to add a new encoding step for state serialization.
  • You may re-use the same StateView instance for multiple clients, or create a new one for each client.
  • Internally, all “shared” properties (properties not tagged with .view()) are serialized first, and then each StateView is serialized with its own set of properties.

Tagging fields with .view()

The .view() field modifier (or @view() decorator) is used to tag a field as only visible to StateView instances that contain that Schema instance.

MyState.ts
const Player = schema({
    // visible to all
    name: t.string(),
 
    // only visible to clients containing this schema instance on their `StateView`
    position: t.number().view(),
}, "Player");

In the example above, the position field is only visible to clients that contain this Player instance in their StateView.

Adding a schema instance to a StateView

To add a schema instance to a StateView, call .add() on the StateView instance:

MyRoom.ts
import { StateView } from "@colyseus/schema";
// ...
    onJoin(client, options) {
        const player = new Player();
        this.state.players.set(client.sessionId, player);
 
        client.view = new StateView();
        client.view.add(player);
    }

The frontend receives either an onAdd or a listen callback, depending on where the .view() tag is. See Listening for StateView changes on the frontend.

Removing a schema instance from a StateView

To remove a schema instance from a StateView, call .remove() on the StateView instance:

client.view.remove(player);

The frontend receives either an onRemove or a listen callback, depending on where the .view() tag is.

Checking if instance is part of StateView

You can check if a schema instance is part of a StateView by calling .has() on the StateView instance:

if (client.view.has(player)) {
    // player is part of this client's StateView
}

Specialized tags with .view(tag: number)

Sometimes you may want to have multiple views with different fields.

MyState.ts
const Player = schema({
    // visible to all
    name: t.string(),
 
    // any `.add(player)` will see this field
    health: t.number().view(),
 
    // only `.add(player, 1)` will see this field
    position: t.number().view(1),
}, "Player");

You can assign a numeric tag to the .view() modifier. That field is then only visible to clients whose StateView contains this Schema instance with the same tag:

MyRoom.ts
// ...
    onJoin(client, options) {
        const player = new Player().assign({ name: "Player 1", health: 100, position: 0 });
        this.state.players.set(client.sessionId, player);
 
        client.view = new StateView();
        client.view.add(player, 1); // add with tag 1 - "position" field is visible
    }
// ...

In the example above, only clients that added this Player instance with tag 1 can see the position field. The health field is visible to all clients that contain this Player instance in their StateView.


The following table shows the relation between type annotations and the visibility of each field on the frontend:

Field declarationWithout view.add()view.add(instance)view.add(instance, 1)
t.*(...) / @type(...)✅✅✅
t.*(...).view() / @view() @type(...)❌✅✅
t.*(...).view(1) / @view(1) @type(...)❌❌✅

Items of ArraySchema and MapSchema

When you tag an array or map with .view(), elements added to the collection later must be added to the client’s StateView individually. (Calling view.add(collection) also brings in the elements it holds at that moment.)

To grant a client every future element automatically, subscribe to the collection instead of adding each element by hand.

MyState.ts
const Player = schema({
    name: t.string(),
    position: t.number(),
}, "Player");
 
const MyState = schema({
    players: t.map(Player).view(),
}, "MyState");

The instance must be assigned to the state and added to the StateView:

MyRoom.ts
import { StateView } from "@colyseus/schema";
// ...
    onJoin(client, options) {
        const player = new Player();
        this.state.players.set(client.sessionId, player);
 
        client.view = new StateView();
        client.view.add(player);
    }
// ...

Subscribing to a collection

view.add(instance) is a one-shot: it grants visibility of that instance, and nothing else. For a collection that keeps gaining elements, that means calling .add() again for every new one. Forgetting a call silently leaves that client behind.

view.subscribe(collection) makes it standing. The view receives all future content changes of that collection, with no further bookkeeping:

MyRoom.ts
import { StateView } from "@colyseus/schema";
// ...
    onJoin(client, options) {
        client.view = new StateView();
 
        // every enemy, present and future, reaches this client
        client.view.subscribe(this.state.enemies);
    }
// ...

Subscribing brings in the collection’s current contents as well as future ones, so it replaces the initial view.add(collection) rather than supplementing it. The call is idempotent. Subscribing to an already-subscribed collection is a no-op.

How new elements are delivered depends on the collection kind:

  • ArraySchema / MapSchema / SetSchema / CollectionSchema: new children are shipped immediately, as they’re added.
  • Streamed collections (t.stream(X), or any collection with .stream()): new elements enter the view’s pending queue and drain a bounded number per tick, in priority order.

On a streamed collection, a second argument orders that client’s backlog. The callback receives the candidate element, so it can close over whatever this client sorts by:

MyRoom.ts
client.view.subscribe(this.state.enemies, (enemy) =>
    -((enemy.x - player.x) ** 2 + (enemy.y - player.y) ** 2));   // nearest first

Subscribing again with a new callback replaces it; null drops it. See Priority for the full picture.

Subscribing is per-view. If several clients share one StateView instance, one subscribe() call covers all of them.

Listening for StateView changes on the frontend

The frontend has no StateView API. When the server adds an instance to a client’s view, that client receives the data as a regular state change. You listen for it with the same state sync callbacks you use for the rest of the state.

Which callback fires depends on where the .view() tag is:

Tagged on the serverview.add(instance) triggersview.remove(instance) triggers
The collection: t.map(Player).view()onAdd on the collectiononRemove on the collection
A field: position: t.number().view()listen on the field, with the valuelisten on the field, with undefined

Tagged collections: onAdd and onRemove

When the collection is tagged, a client only sees the items in its view. Each view.add() and view.remove() call reaches the frontend as an item being added to or removed from the collection:

client.ts
import { Client, Callbacks } from "@colyseus/sdk";
 
const client = new Client("http://localhost:2567");
const room = await client.joinOrCreate("my_room");
const callbacks = Callbacks.get(room);
 
// server called client.view.add(player)
callbacks.onAdd("players", (player, sessionId) => {
    console.log("player is now visible:", sessionId);
});
 
// server called client.view.remove(player)
callbacks.onRemove("players", (player, sessionId) => {
    console.log("player is no longer visible:", sessionId);
});

Items added through view.subscribe() trigger the same onAdd callback.

Tagged fields: listen

When only some fields are tagged, every client receives the instance itself. Its tagged fields stay undefined until the server adds the instance to that client’s view. Listen to the field to know when it is revealed or hidden:

client.ts
callbacks.onAdd("players", (player, sessionId) => {
    // player.position is undefined until the server calls client.view.add(player)
    callbacks.listen(player, "position", (value, previousValue) => {
        if (value === undefined) {
            // server called client.view.remove(player)
        } else {
            // revealed by client.view.add(player), or updated afterwards
        }
    });
});

Fields tagged with a numeric tag behave the same way. They are revealed by view.add(instance, tag) with a matching tag.

You can register callbacks before the data arrives. An onAdd on a tagged collection that this client cannot see yet fires once the server adds items to its view.