State Synchronization

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 Schema class 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:

src/rooms/MyState.ts
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:

src/rooms/MyRoom.ts
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:

client.js
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.

client.js
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.

src/rooms/MyRoom.ts
// ...
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.

client.js
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 Schema structure can hold up to 64 serialized fields. If you need more fields, use nested Schema structures.
  • NaN and Infinity are encoded as 0 (integer encoding only).
  • null strings are encoded as "" (both “string” and “cstring” encoding).
  • Multi-dimensional arrays are not supported. See how to use 1D arrays as multi-dimensional
  • @colyseus/schema encoding 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 Schema instance. Each Schema instance holds a ChangeTree object that tracks its changes.
  • Sending changes: The server encodes only the changed properties and sends them to the client during the patchRate interval.
  • refId: Each Schema instance has a unique refId that 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 Schema instance, based on the refId—triggering the onChange, listen, and onAdd/onRemove callbacks 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.

tsconfig.json
{
    "compilerOptions": {
        "experimentalDecorators": true,
        "useDefineForClassFields": false
    }
}

Next Steps