
An incremental binary state serializer with delta encoding for games.
Made for Colyseus, yet can be used standalone.
- Incremental State Synchronization: Send only the properties that have changed.
- Trigger Callbacks at Decoding: Bring your own callback system at decoding, or use the built-in one.
- Instance Reference Tracking: Share references of the same instance across the state.
- State Views: Filter properties that should be sent only to specific clients.
- Reflection: Encode/Decode schema definitions.
- Schema Generation: Generate client-side schema files for strictly typed languages.
- Type Safety: Strictly typed schema definitions.
- Multiple Language Support: Decoders available for multiple languages (C#, Lua, Haxe).
@colyseus/schema
uses type annotations to define types of synchronized properties.
import { Schema, type, ArraySchema, MapSchema } 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('string') fieldString: string;
@type('number') fieldNumber: number;
@type(Player) player: Player;
@type([ Player ]) arrayOfPlayers: ArraySchema<Player>;
@type({ map: Player }) mapOfPlayers: MapSchema<Player>;
}
Type | Description | Limitation |
---|---|---|
string | utf8 strings | maximum byte size of 4294967295 |
number | auto-detects int or float type. (extra byte on output) |
0 to 18446744073709551615 |
boolean | true or false |
0 or 1 |
int8 | signed 8-bit integer | -128 to 127 |
uint8 | unsigned 8-bit integer | 0 to 255 |
int16 | signed 16-bit integer | -32768 to 32767 |
uint16 | unsigned 16-bit integer | 0 to 65535 |
int32 | signed 32-bit integer | -2147483648 to 2147483647 |
uint32 | unsigned 32-bit integer | 0 to 4294967295 |
int64 | signed 64-bit integer | -9223372036854775808 to 9223372036854775807 |
uint64 | unsigned 64-bit integer | 0 to 18446744073709551615 |
float32 | single-precision floating-point number | -3.40282347e+38 to 3.40282347e+38 |
float64 | double-precision floating-point number | -1.7976931348623157e+308 to 1.7976931348623157e+308 |
@type("string")
name: string;
@type("int32")
name: number;
@type(Player)
player: Player;
@type([ Player ])
arrayOfPlayers: ArraySchema<Player>;
You can't mix types inside arrays.
@type([ "number" ])
arrayOfNumbers: ArraySchema<number>;
@type([ "string" ])
arrayOfStrings: ArraySchema<string>;
@type({ map: Player })
mapOfPlayers: MapSchema<Player>;
You can't mix primitive types inside maps.
@type({ map: "number" })
mapOfNumbers: MapSchema<number>;
@type({ map: "string" })
mapOfStrings: MapSchema<string>;
The Schema definitions can encode itself through Reflection
. You can have the
definition implementation in the server-side, and just send the encoded
reflection to the client-side, for example:
import { Schema, type, Reflection } from "@colyseus/schema";
class MyState extends Schema {
@type("string") currentTurn: string;
// ... more definitions
}
// send `encodedStateSchema` across the network
const encodedStateSchema = Reflection.encode(new MyState());
// instantiate `MyState` in the client-side, without having its definition:
const myState = Reflection.decode(encodedStateSchema);
You can use @view()
to filter properties that should be sent only to StateView
's that have access to it.
import { Schema, type, view } from "@colyseus/schema";
class Player extends Schema {
@view() @type("string") secret: string;
@type("string") notSecret: string;
}
class MyState extends Schema {
@type({ map: Player }) players = new MapSchema<Player>();
}
Using the StateView
const view = new StateView();
view.add(player);
There are 3 major features of the Encoder
class:
- Encoding the full state
- Encoding the state changes
- Encoding state with filters (properties using
@view()
tag)
import { Encoder } from "@colyseus/schema";
const state = new MyState();
const encoder = new Encoder(state);
New clients must receive the full state on their first connection:
const fullEncode = encoder.encodeAll();
// ... send "fullEncode" to client and decode it
Further state changes must be sent in order:
const changesBuffer = encoder.encode();
// ... send "changesBuffer" to client and decode it
When using @view()
and StateView
's, a single "full encode" must be used for multiple views. Each view also must add its own changes.
// shared buffer iterator
const it = { offset: 0 };
// shared full encode
encoder.encodeAll(it);
const sharedOffset = it.offset;
// view 1
const fullEncode1 = encoder.encodeAllView(view1, sharedOffset, it);
// ... send "fullEncode1" to client1 and decode it
// view 2
const fullEncode2 = encoder.encodeAllView(view2, sharedOffset, it);
// ... send "fullEncode" to client2 and decode it
Encoding changes per views:
// shared buffer iterator
const it = { offset: 0 };
// shared changes encode
encoder.encode(it);
const sharedOffset = it.offset;
// view 1
const view1Encoded = this.encoder.encodeView(view1, sharedOffset, it);
// ... send "view1Encoded" to client1 and decode it
// view 2
const view2Encoded = this.encoder.encodeView(view2, sharedOffset, it);
// ... send "view2Encoded" to client2 and decode it
// discard all changes after encoding is done.
encoder.discardChanges();
The Decoder
class is used to decode the binary data received from the server.
import { Decoder } from "@colyseus/schema";
const state = new MyState();
const decoder = new Decoder(state);
decoder.decode(encodedBytes);
Backwards/fowards compatibility is possible by declaring new fields at the
end of existing structures, and earlier declarations to not be removed, but
be marked @deprecated()
when needed.
This is particularly useful for native-compiled targets, such as C#, C++, Haxe, etc - where the client-side can potentially not have the most up-to-date version of the schema definitions.
- Each
Schema
structure can hold up to64
fields. If you need more fields, use nested structures. NaN
ornull
numbers are encoded as0
null
strings are encoded as""
Infinity
numbers are encoded asNumber.MAX_SAFE_INTEGER
- Multi-dimensional arrays are not supported.
- Items inside Arrays and Maps must be all instance of the same type.
@colyseus/schema
encodes only field values in the specified order.- Both encoder (server) and decoder (client) must have same schema definition.
- The order of the fields must be the same.
If you're using JavaScript or LUA, there's no need to bother about this. Interpreted programming languages are able to re-build the Schema locally through the use of
Reflection
.
You can generate the client-side schema files based on the TypeScript schema definitions automatically.
# C#/Unity
schema-codegen ./schemas/State.ts --output ./unity-project/ --csharp
# C/C++
schema-codegen ./schemas/State.ts --output ./cpp-project/ --cpp
# Haxe
schema-codegen ./schemas/State.ts --output ./haxe-project/ --haxe
Scenario | @colyseus/schema |
msgpack + fossil-delta |
---|---|---|
Initial state size (100 entities) | 2671 | 3283 |
Updating x/y of 1 entity after initial state | 9 | 26 |
Updating x/y of 50 entities after initial state | 342 | 684 |
Updating x/y of 100 entities after initial state | 668 | 1529 |
Each Colyseus SDK has its own decoder implementation of the @colyseus/schema
protocol:
Initial thoghts/assumptions, for Colyseus:
- little to no bottleneck for detecting state changes.
- have a schema definition on both server and client
- better experience on staticaly-typed languages (C#, C++)
- mutations should be cheap.
Practical Colyseus issues this should solve:
- Avoid decoding large objects that haven't been patched
- Allow to send different patches for each client
- Better developer experience on statically-typed languages
MIT