State Sync
Overview
Colyseus uses a schema-based approach to define the state of a room. The server is responsible for mutating the state, and the client listens for state changes to keep the user interface in sync.
- The room’s state is defined using the
Schemaclass from@colyseus/schema. Only the server can directly mutate the state. - Clients send messages to the server to request state changes. Your room code processes these requests and updates the state.
- Colyseus optimizes performance and bandwidth by tracking property-level changes. Only the latest mutation of each property is queued and sent to clients during the patchRate interval.
- On the client side, you listen for state changes to keep the user interface in sync.
Backend: Define your state structures
Define your state structures by extending the Schema class from @colyseus/schema:
import { Schema, type } from "@colyseus/schema";
export class Player extends Schema {
@type("string") name: string;
@type("number") x: number;
@type("number") y: number;
}
export class MyState extends Schema {
@type({ map: Player }) players = new MapSchema<Player>();
}… assign and mutate the state
Setting up the state in your room class and mutating it when clients join or leave the room:
import { Room } from "colyseus";
import { MyState, Player } from "./MyState";
export class MyRoom extends Room<MyState> {
state = new MyState();
onJoin (client, options) {
this.state.players.set(client.sessionId, new Player());
}
onLeave (client) {
this.state.players.delete(client.sessionId);
}
}Frontend: Full state received on join
Clients receive the full state when they join the room. Whenever a mutation occurs in the backend, the state is automatically synchronized with all clients in the room.
Below is an example of how to listen to player additions and removals on the frontend:
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);
// Listen to 'player' instance additions
callbacks.onAdd("players", (player, sessionId) => {
console.log('Player joined:', player);
});
// Listen to 'player' instance removals
callbacks.onRemove("players", (player, sessionId) => {
console.log('Player left:', player);
});… request the server to mutate the state
The frontend is not capable of mutating the state directly. Instead, it sends messages to the server to request state changes.
import { Client, Callbacks } from '@colyseus/sdk';
// ...
room.send("set-position", { x: 16, y: 16 });Backend: Listen to client messages
The backend processes the client messages and mutates the state. Colyseus will take care of synchronizing the state with all clients in the room.
// ...
export class MyRoom extends Room<MyState> {
state = new MyState();
messages = {
"set-position": (client, data) => {
const player = this.state.players.get(client.sessionId);
player.x = data.x;
player.y = data.y;
}
}
// ...Frontend: Listen to state changes
The client listens to state changes on the instance directly to keep the user interface in sync.
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);
// Listen to 'player' instance additions
callbacks.onAdd("players", (player, sessionId) => {
// Listening for any change on the player instance
callbacks.onChange(player, () => {
console.log('Player changed:', player.x, player.y);
});
});Limitations and Best Practices
- Each
Schemastructure can hold up to64serialized fields. If you need more fields, use nestedSchemastructures. NaNandInfinityare encoded as0(integer encoding only).nullstrings are encoded as""(both “string” and “cstring” encoding).- Multi-dimensional arrays are not supported. See how to use 1D arrays as multi-dimensional
@colyseus/schemaencoding order is based on field definition order.- Both encoder (server) and decoder (client) must have the same schema definition.
- The order of the fields must be the same.
How does it work, internally?
- Handshake: When a client joins a room, the server sends all the types that compose the room’s state to the client, followed by the full state.
- Handshake is skipped on automatic reconnections, OR when the client has provided the concrete state classes when joining the room.
- Enqueueing changes: When the server mutates the state, it tracks which properties have changed since the last state synchronization, per
Schemainstance. EachSchemainstance holds aChangeTreeobject that tracks its changes. - Sending changes: The server encodes only the changed properties and sends them to the client during the patchRate interval.
refId: EachSchemainstance has a uniquerefIdthat is used to identify the instance across the network. This is how Colyseus knows which instance has been added, removed, or updated.- Decoding changes: When the client receives the state changes, it decodes them and applies them to each
Schemainstance, based on therefId—triggering theonChange,listen, andonAdd/onRemovecallbacks on the frontend.
Troubleshooting
TypeScript Config
If you are using TypeScript, make sure to enable the experimentalDecorators and disable useDefineForClassFields in your tsconfig.json file.
{
"compilerOptions": {
"experimentalDecorators": true,
"useDefineForClassFields": false
}
}Next Steps
- Schema Definition - Complete reference for defining state structures
- State Sync Callbacks - All methods for listening to state changes
- State View - Control which parts of state each client can see
- Best Practices - Tips for efficient state design