State SyncAdvanced Schema Usage

Advanced Schema Customization

Since @colyseus/schema 3.0, experimental APIs are available allowing you to customize:

Custom types and encoding

You can define custom types to encode and decode your data structures.

Custom type identifiers currently require the decorator style. See Decorators for the required tsconfig flags.

import { Schema, defineCustomTypes } from "@colyseus/schema";
import { TextDecoder, TextEncoder } from "util";
 
const _encoder = new TextEncoder();
const _decoder = new TextDecoder();
 
const customType = defineCustomTypes({
        cstring: {
            encode: (bytes, value, it) => {
                value ??= "";
                value += "\x00";
                if (bytes instanceof Uint8Array) {
                    it.offset += _encoder.encodeInto(value, bytes.subarray(it.offset)).written;
                } else {
                    const encoded = _encoder.encode(value);
                    const len = encoded.length;
                    for (let i = 0; i < len; ++i) bytes[it.offset++] = encoded[i]; // could probably also figure out if bytes has .set
                }
            },
            decode: (bytes, it) => {
                // should short circuit if buffer length can't be determined for some reason so we don't just infinitely loop
                const len = (bytes as Buffer | ArrayBuffer).byteLength ?? (bytes as number[]).length;
                if (len === undefined) throw TypeError("Unable to determine length of 'BufferLike' " + bytes.toString());
                let start = it.offset;
                while (it.offset < len && bytes[it.offset++] !== 0x00) { }; // nop, fast search for terminator
                return _decoder.decode(new Uint8Array((bytes as Buffer | Uint8Array)?.subarray?.(start, it.offset - 1) ?? bytes.slice(start, it.offset - 1))); // ignore terminator
            }
        },
});
 
class MyState extends Schema {
    @customType("cstring") message: string;
}

See CustomPrimitiveTypes.test.ts for more examples, which includes:

TypeDescriptionLimitationSize (Bytes)
"varInt"signed variable-length encoded integer (number type)-2147483648 to 2147483647 (safely)1 - 8 (depending on bits used)
"varUint"unsigned variable-length encoded integer (number type)0 to 4294967296 (safely)1 - 8 (depending on bits used)
"varBigInt"signed variable-length encoded integer (bigint type)limitations based on platforms bigint implementation1 - ? (depending on the bits used)
"varBigUint"unsigned variable-length encoded integer (bigint type)limitations based on platforms bigint implementation1 - ? (depending on the bits used)
"varFloat32"single-precision variable-length encoded floating-point number-3.40282347e+38 to 3.40282347e+382 - 6 (depending on bits used)
"varFloat64"double-precision variable-length encoded floating-point number-1.7976931348623157e+308 to 1.7976931348623157e+3082 - 10 (depending on the bits used)

Change Tracking

The $track method is called whenever a synchronized attribute gets mutated, and marks which properties must be encoded. The $track/$encoder/$decoder hooks below attach to any Schema class, hand-written or returned by schema().

Vec3.ts
import { $track, Schema, OPERATION } from "@colyseus/schema";
 
class Vec3 extends Schema {
    x: number;
    y: number;
    z: number;
}
 
Vec3[$track] = function (changeTree, index, operation = OPERATION.ADD) {
    changeTree.change(index, operation);
};

See ChangeTree for more information.

Byte-level encoding

At the $encoder method call, you may customize how a particular structure gets encoded into the buffer that is sent over the wire for the client.

Vec3.ts
import { $encoder, Schema } from "@colyseus/schema";
 
class Vec3 extends Schema {
    x: number;
    y: number;
    z: number;
}
 
Vec3[$encoder] = function (encoder, buffer, changeTree, index, operation, it, isEncodeAll, hasView) {
    //
    // encode x / y / z into a single byte
    // (this limits for values ranging from 0 to 7 for x, y, and z.)
    //
    const { x, y, z } = changeTree.ref;
    buffer[it.offset++] = (x << 6) | (y << 3) | z;
}

See EncodeOperation method signature for full list of arguments.

Byte-level decoding

At the $decoder method call, you may customize how a particular structure gets decoded, and how to interact with the callback system.

Vec3.ts
import { $decoder, $refId, Schema, OPERATION } from "@colyseus/schema";
 
class Vec3 extends Schema {
    x: number;
    y: number;
    z: number;
}
 
Vec3[$decoder] = function (decoder, bytes, it, ref, allChanges) {
    //
    // decode x / y / z from a single byte
    // (values can only range from 0 to 7)
    //
    const byte = bytes[it.offset++];
 
    ref.x = (byte >> 6) & 0x07;
    ref.y = (byte >> 3) & 0x07;
    ref.z = byte & 0x07;
 
    //
    // (optional) add change to list of changes, for callback handling
    //
    allChanges.push({
        ref: ref,
        refId: ref[$refId],
        op: OPERATION.REPLACE,
        field: "x",
        value: ref.x,
        previousValue: undefined,
    });
 
    allChanges.push({
        ref: ref,
        refId: ref[$refId],
        op: OPERATION.REPLACE,
        field: "y",
        value: ref.y,
        previousValue: undefined,
    });
 
    allChanges.push({
        ref: ref,
        refId: ref[$refId],
        op: OPERATION.REPLACE,
        field: "z",
        value: ref.z,
        previousValue: undefined,
    });
}

See DecodeOperation method signature for full list of arguments.

Encoding non-Schema structures

In order to encode 3rd party structures, there are 2 steps to take:

  1. Use Metadata.setFields() to define the properties to be encoded.
  2. Initialize each instance with Schema.initialize() as soon as the 3rd party structure has been instantiated.
⚠️

Possible conflicts with 3rd party libraries

  • Schema.initialize() is going to assign a property descriptor per property defined by Metadata.setFields().
  • If the 3rd party library you use also defines their own property descriptors (or use getters/setters for such properties), synchronization will not work as expected.
Vec3.ts
import { Schema, Metadata } from "@colyseus/schema";
 
// the 3rd party structure...
class Vec3 {
    x: number;
    y: number;
    z: number;
}
 
// define how to encode the properties
Metadata.setFields(Vec3, {
    x: "number",
    y: "number",
    z: "number",
});
 
// initialize it!
const vec3 = new Vec3();
Schema.initialize(vec3);
 

Instance pooling

Constructing a Schema is not free: each instance gets its own change-tracking state, a property descriptor, and a values array. Every node in an entity tree multiplies that cost. A room that spawns and despawns entities continuously (projectiles, ECS entities, bots) pays it over and over.

createPool() recycles instances instead:

import { createPool } from "@colyseus/schema";
 
const pool = createPool(Entity, { preallocate: 1000 });
 
// spawn
const entity = pool.acquire();
entity.x = 10;
entity.y = 20;
state.entities.set(id, entity);
 
// despawn: detach from the state tree FIRST, then release
state.entities.delete(id);
pool.release(entity);
MemberDescription
createPool(ctor, opts?)Creates a pool for a zero-argument Schema class
pool.acquire()Returns a free instance, or constructs one if the pool is empty
pool.release(instance)Resets the instance and returns it to the pool
pool.sizeInstances currently available for reuse

Options:

OptionDefaultDescription
preallocate0Construct this many instances up front, so early spawns reuse rather than allocate
maxSizeunboundedCap on retained free instances; releases beyond it are dropped and garbage-collected

Reuse is invisible on the wire

release() resets the instance to its construction defaults and drops its internal ref id, so re-adding it acquires a fresh one. A pooled instance therefore encodes byte-identically to a freshly constructed one. Clients cannot tell the difference, and a late joiner receiving a full state sync sees a consistent state regardless of how much churn preceded it.

Order matters: detach, then release

⚠️

Remove the instance from its parent collection before calling release(). Releasing an instance that is still attached throws: it would otherwise be reset while the encoder still considered it part of the state tree.

state.entities.delete(id);   // detach first
pool.release(entity);        // then release

Re-assign every field after acquire()

⚠️

Pooling does not clear primitive values. An acquired instance still carries the numbers, strings and booleans from its previous life. Those retained values are re-encoded when it re-enters the state tree. A field you forget to re-assign will silently ship the previous entity’s value to clients.

Treat acquire() as new and assign every field you would have set on a fresh instance. Ref-type children (nested schemas, collections) are reset recursively, so a pooled entity’s collections come back empty.

Restrictions

release() throws when the instance is:

  • still attached to the state tree (see above);
  • shared across multiple parents: another owner may still hold it, so recycling it is unsafe;
  • decoder-side: client mirror instances aren’t change-tracked and can’t be pooled. Pooling is a server-side optimization only;
  • holding a streamed collection field: a .stream() map, set, collection or array cannot be reset, since per-client delivery state outlives the instance.