State Sync

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:

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

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

  • Each Schema structure can hold up to 63 serialized fields. If you need more fields, use nested Schema structures.
    • Inherited fields count toward the limit, so check the parent class too. Defining the 64th field throws where the class is defined.
  • NaN is encoded as 0. Infinity is encoded as the largest safe integer, which costs 9 bytes.
  • null strings are encoded as "".
  • 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: the order fields appear in the schema() 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 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. The refId 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. Decoding triggers the onChange, listen, and onAdd/onRemove callbacks 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