Colyseus 0.10: Introducing the New State Serialization Algorithm

I’m really excited to announce the release 0.10 of Colyseus. This version basically introduces a more performant state serialization algorithm and a different way of decoding the state in the client-side. It’s currently available for JavaScript, Defold Engine and Unity3D.

The new serializer (@colyseus/schema) optimizes not only the throughput of your application but also memory and CPU consumption on both server-side and client-side. See the comparison between the number of bytes transferred using previous and new serializer of Colyseus.

This is possible through strictly defining the data types of each property in the state. Only types with the “@type” annotation are going to be synchronized. There’s no need to use “@nosync” non-synchronizable properties anymore.

What was the problem with the previous serializer?

Besides being pretty easy to use, the previous serializer had some disadvantages both when encoding AND decoding large state objects.

  • The server needed to take a snapshot of the state at every patch, even if no changes were made — to be able to check if the previous state snapshot differs from the current one. The patch was then broadcasted as a binary diff between them.
  • To apply the changes in the client-side, the patches were applied on top of the last snapshot sent by the server — which means the whole state needed to be deserialized again, creating a new copy of the state per patch and having a performance penalty if your state was slightly big.

The good news is that the new serializer solves both of these problems.

The New Serializer — Server-side

In the state definition below, you’ll see a map of Player instances.

import { Schema, MapSchema, type } from "@colyseus/schema";

class Player extends Schema {
@type("number")
width: number;

@type("number")
height: number;
}

class MyState extends Schema {
@type({ map: Player })
players = new MapSchema<Player>();
}

Now, let’s instantiate the state in our room handler:

import { Room } from "colyseus";
import { MyState } from "./MyState";
class MyRoom extends Room {
onInit() {
this.setState(new MyState());
}
}

In the client-side, the state is available right after successfully joining the room. You can listen for additions, removals, and changes by registering callbacks directly into your decoded state variables.

The New Serializer — Client-side (JavaScript)

The way you listen for a change in the client-side depends on the structure you’re interested in. Variable changes within a container are available through the “onChange” callback:

var client = new Colyseus.Client("ws://localhost:2567");
var room = client.join("my_room");
room.onJoin.add(() => {
room.state.onChange = (changes) => {
changes.forEach(change => {
console.log(change.field);
console.log(change.value);
console.log(change.previousValue);
});
};
})

Additions and removals of entries on Array’s and Map’s are available through “onAdd” and “onRemove” callbacks in the container variable.

room.state.players.onAdd = (player, key) => {
console.log(player, "has been added at", key);
};
room.state.players.onRemove = (player, key) => {
console.log(player, "has been removed at", key);
};

For child entities inside Array’s and Map’s, you may detect changes of either the whole object or particular variables:

// callback for a whole object change 
// not possible to know which properties have changed here
room.state.players.onChange = (player, key) => {
console.log(player, "has been changed at", key);
};
// callback for checking particular property changes
// this way you know exactly which properties have changed
room.state.players.onAdd = (player, key) => {
player.onChange = function(changes) {
changes.forEach(change => {
console.log(change.field);
console.log(change.value);
console.log(change.previousValue);
});
}
};

See the migration guide from version 0.9 to 0.10 here.

That’s it for 0.10

I highly suggest you upgrade to version 0.10, even if you’re not going to use the new serializer yet. The previous serializer is still available and won’t be deprecated any time soon.

Next challenge: data filters! (work in progress)

I’ve started experimenting to allow filtering the data in the state for each client. The plan is to have different callbacks in the client-side whenever the data becomes available or unavailable.

The syntax for data filtering will most likely look like the example below — which demonstrates filtering entities by distance.

import { Schema, type, filter } from "@colyseus/schema";

export class State extends Schema {
@filter(function(this: State, client: any, value: Entity) {
const currentPlayer = this.entities[client.sessionId]

var a = value.x - currentPlayer.x;
var b = value.y - currentPlayer.y;

return (Math.sqrt(a * a + b * b)) <= 10;

})
@type({ map: Entity })
entities = new MapSchema<Entity>();
}

Feedback is always very welcome!

Feel free to join the Discord server if you have any suggestions. If you just want to hang out with us, you’re welcome too!

Software Engineer & Indie Game Developer