State Synchronization
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
schema()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 frontend, you listen for state changes to keep the user interface in sync.
Backend: Define your state structures
Define your state structures using schema() and t.* field builders from @colyseus/schema, or the classic @type() decorator style:
import { schema, t, type SchemaType } from "@colyseus/schema";
export const Player = schema({
name: t.string(),
x: t.number(),
y: t.number(),
}, "Player");
export type Player = SchemaType<typeof Player>;
export const MyState = schema({
players: t.map(Player),
}, "MyState");
export type MyState = SchemaType<typeof MyState>;The type aliases are TypeScript-only sugar: schema() returns a value, and SchemaType derives the instance type under the same name. Plain JavaScript users omit those lines.
… 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
- Each
Schemastructure can hold up to63serialized fields. If you need more fields, use nestedSchemastructures.- Inherited fields count toward the limit, so check the parent class too. Defining the 64th field throws where the class is defined.
NaNis encoded as0.Infinityis encoded as the largest safe integer, which costs 9 bytes.nullstrings are encoded as"".- Multi-dimensional arrays are not supported. See how to use 1D arrays as multi-dimensional
@colyseus/schemaencoding order is based on field definition order: the order fields appear in theschema()object literal (or, for decorators, declaration order).- Both encoder (server) and decoder (client) must have the same schema definition.
- The order of the fields must be the same.
For what a patch costs in bytes and how to make it smaller, see Optimizing State.
How does it work, internally?
- Handshake: When a client joins a room, the server sends all the types that compose the room’s state, 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. TherefIdis 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. Decoding triggers theonChange,listen, andonAdd/onRemovecallbacks on the frontend.
Troubleshooting
The schema() builder needs no compiler configuration. If you use the @type() decorator style, two tsconfig.json flags are required. See Decorators → TypeScript Config.
Next Steps
- Schema Definition - Complete reference for defining state structures
- Decorators (@type) - The classic decorator-based definition style
- State Sync Callbacks - All methods for listening to state changes
- State View - Control which parts of state each client can see
- Optimizing State - What a patch costs, and how to make it smaller