Advanced Schema Customization
Since @colyseus/schema 3.0, experimental APIs are available allowing you to
customize:
- Custom types and encoding
- Change tracking
- Byte-level encoding
- Byte-level decoding
- Encoding non-
Schemastructures - Instance pooling
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:
| Type | Description | Limitation | Size (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 implementation | 1 - ? (depending on the bits used) |
"varBigUint" | unsigned variable-length encoded integer (bigint type) | limitations based on platforms bigint implementation | 1 - ? (depending on the bits used) |
"varFloat32" | single-precision variable-length encoded floating-point number | -3.40282347e+38 to 3.40282347e+38 | 2 - 6 (depending on bits used) |
"varFloat64" | double-precision variable-length encoded floating-point number | -1.7976931348623157e+308 to 1.7976931348623157e+308 | 2 - 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().
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.
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.
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:
- Use
Metadata.setFields()to define the properties to be encoded. - 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 byMetadata.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.
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);| Member | Description |
|---|---|
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.size | Instances currently available for reuse |
Options:
| Option | Default | Description |
|---|---|---|
preallocate | 0 | Construct this many instances up front, so early spawns reuse rather than allocate |
maxSize | unbounded | Cap 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 releaseRe-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.