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:
- Assigning a
StateViewinstance to the client - Tag state fields with the
.view()field modifier - Manually
.add()schema instances to theStateView - Manually
.remove()schema instances from theStateView
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
import { StateView } from "@colyseus/schema";
// ...
onJoin(client, options) {
client.view = new StateView();
// ...
}
// ...How serialization works
- Each
StateViewinstance is going to add a new encoding step for state serialization. - You may re-use the same
StateViewinstance 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 eachStateViewis 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.
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:
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.
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:
// ...
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 declaration | Without 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.
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:
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:
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:
client.view.subscribe(this.state.enemies, (enemy) =>
-((enemy.x - player.x) ** 2 + (enemy.y - player.y) ** 2)); // nearest firstSubscribing 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 server | view.add(instance) triggers | view.remove(instance) triggers |
|---|---|---|
The collection: t.map(Player).view() | onAdd on the collection | onRemove on the collection |
A field: position: t.number().view() | listen on the field, with the value | listen 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:
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:
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.