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 will receive either an “On Add” or “Listen” callback, depending on which structure the schema instance is part of.
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 will receive either an “On Remove” or “Listen” callback, depending on which structure the schema instance is part of.
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.