Schema Definition
- Schema structures are defined on the backend and describe data that is continuously synchronized between server and client.
- A field is synchronized when it is declared: with a
t.*builder, or with a@type()decorator. Plain class properties are invisible to the serializer. - A declared field can opt out with
.noSync(), and how it reaches clients is tunable with delivery modifiers.
Schema is for the two things that synchronize continuously: your room’s state, and the input clients send back. Don’t reach for it elsewhere. Room messages and other one-off payloads have their own serialization and don’t need a schema.
Coming from @type() decorators? Every example below offers a Decorators tab, and your selection is remembered across pages. Decorator-specific setup and details live in Decorators.
Defining a Schema structure
import { schema, t } from "@colyseus/schema";
export const MyState = schema({
currentTurn: t.string(),
}, "MyState");schema() returns a real class: instanceof works, and you instantiate it with new. It runs in plain JavaScript and TypeScript alike, with no compiler configuration.
Each field takes a t.* builder. Since schema 5 (Colyseus 0.18), raw strings like "string" as field values are no longer accepted and throw at definition time. (They remain valid as collection child types, e.g. t.array("string").)
Assign an instance to your room’s state property to start synchronizing it. State Synchronization walks through the full loop, from definition to client callbacks.
The sections below cover each aspect of a schema definition, in both styles. The tabs remember your choice across pages.
Deriving the TypeScript type
schema() returns a value, not a type. To reference the instance type in TypeScript (Room<MyState>, function parameters, etc.), declare a type alias with the same name using SchemaType:
import { schema, t, type SchemaType } from "@colyseus/schema";
export const Player = schema({
x: t.number(),
y: t.number(),
}, "Player");
export type Player = SchemaType<typeof Player>;The const and the type merge under one name, so Player works as both: new Player() and function move(player: Player). Plain JavaScript users omit the type line.
Naming schemas
The serializer identifies each structure by its runtime class name. An anonymous class degrades error messages, debug output and cross-language code generation.
The second argument of schema() sets the runtime class name. Pass it for every state structure:
const Player = schema({ /* ... */ }, "Player");Default values
Chain .default(value) to set a field’s initial value. Passing a function makes it a factory, invoked once per construction. Use it for any mutable default that must not be shared across instances:
const Player = schema({
hp: t.uint8().default(100),
inputQueue: t.ref(Array).noSync().default(() => []), // fresh array per instance
}, "Player");Fields without an explicit default are auto-instantiated when possible: collections start as an empty ArraySchema/MapSchema/SetSchema/CollectionSchema, and a child schema field starts as new Child() when the child requires no construction arguments. You don’t write the decorator style’s property initializers with the builder.
Methods and constructors
Function-valued properties become methods on the resulting class. The special initialize(props) method acts as the constructor body:
const Player = schema({
name: t.string(),
hp: t.uint8().default(100),
initialize(props: { name: string }) {
this.name = props.name.trim();
},
takeDamage(amount: number) {
this.hp -= amount;
},
}, "Player");
const player = new Player({ name: "Alice " });
player.takeDamage(10);- Without an
initialize(), the constructor already accepts a partial props object:new Player({ hp: 50 }). - With an
initialize(), its parameter becomes the constructor signature: a required parameter makesnew Player()a type error, and assigning the schema fields frompropsisinitialize()’s job. initialize()runs only for the exact class being constructed. When extending, invoke the parent’s explicitly withParent.prototype.initialize.call(this, props).
Field modifiers
Every t.* builder accepts chainable modifiers. Most have a decorator equivalent; the rest are builder-only:
| Modifier | Effect | Decorator equivalent |
|---|---|---|
.default(value | factory) | Initial value; function form builds a fresh value per instance | property initializer |
.view(tag?) | Field visible only through a client’s StateView | @view(tag?) |
.deprecated(throws?) | Retire a field while keeping encoding order (see Versioning) | @deprecated() |
.optional() | Type becomes T | undefined; skips auto-instantiation | — |
.noSync() | Local-only field: typed and initialized, but never synchronized | — |
.unreliable() | Tick patches carry the field on the unreliable channel (see Delivery modifiers) | @unreliable |
.patchOnly() | Sent on tick patches only, never in a full state sync (see Delivery modifiers) | @patchOnly |
.fullStateOnly() | Sent in the full state sync only, never in a tick patch (see Delivery modifiers) | @fullStateOnly |
.stream() | Drip-feed a collection’s additions across ticks (see Streaming) | @type({ stream: X }) |
Delivery modifiers
The modifiers above answer what a field is. These three answer when and how it reaches a client. The value and its type are unaffected, only its delivery.
.unreliable() combines freely with either of the other two. .patchOnly() and .fullStateOnly() are mutually exclusive. They name the only two delivery channels, so a field marked both would never reach a client at all; that throws at schema() time.
.patchOnly() and .fullStateOnly() are server-side encoder concerns: the client needs no setup, its decoder just receives the field or doesn’t. .unreliable() additionally depends on the transport, covered below.
.unreliable()
Tick patches carry the field on the unreliable transport channel, dropped rather than retransmitted when the network is congested. For a value that is fully replaced every tick anyway, a dropped packet costs nothing: the next tick supersedes it. Retransmitting it would only add latency.
Experimental, and WebTransport only. Unreliable delivery needs a datagram channel, which only WebTransport provides today, and that transport is itself experimental.
On every WebSocket transport the field reaches clients once, in the full state sync, and then freezes. Tick patches skip it, and the room logs a warning. Keep the field reliable until the transport is in place.
const Player = schema({
x: t.number(), // reliable: every change must land
y: t.number(),
aimYaw: t.angle().unreliable(), // replaced every tick; a drop is free
}, "Player");Use it for continuously-overwritten values (look angles, interpolated positions, a cosmetic animation blend). Do not use it for anything a client must not miss: inventory, score, health, or any value read as a delta rather than an absolute.
The field is still written to the full state sync, so a late joiner gets the current value on arrival. Combine with .patchOnly() to leave it out of that too.
.patchOnly()
The field travels on tick patches only. A full state sync never includes it, so a client that joins mid-game does not receive it at all. The field appears only when it next changes:
const Explosion = schema({
x: t.number(),
y: t.number(),
flashIntensity: t.number().patchOnly(), // meaningless to a late joiner
}, "Explosion");Use it for values that are only meaningful as they happen: a hit flash, a one-frame impulse, a “last damage taken” readout. Sending them in a full state sync would show a late joiner an event that already finished.
Orthogonal to .unreliable(): .patchOnly() controls whether the field is included in a full state sync, .unreliable() controls which channel patches use. A field marked both is skipped in full syncs and sent unreliably in patches.
.fullStateOnly()
For map and room-wide data that every client needs once: the level layout, the tile grid, spawn points, the match configuration. Data that is decided when the room is created and then simply is.
The field ships in the full state sync only: a client receives it on join, and never again.
const MyState = schema({
// decided at room creation, sent once to each client
mapWidth: t.uint16().fullStateOnly(),
mapHeight: t.uint16().fullStateOnly(),
tiles: t.array("uint8").fullStateOnly(),
spawns: t.array(SpawnPoint).fullStateOnly(),
// the actual game state, patched every tick
players: t.map(Player),
}, "MyState");Room-wide data like this tends to be large and constant, exactly the combination you don’t want anywhere near a per-tick patch. The modifier is a guarantee rather than an optimization you have to keep verifying. The field cannot end up in a patch, however the surrounding code evolves.
That makes it the exact mirror of .patchOnly():
| In the full state sync | In tick patches | |
|---|---|---|
| (default) | ✅ | ✅ |
.patchOnly() | ❌ | ✅ |
.fullStateOnly() | ✅ | ❌ |
Writes after a client has joined are silently dropped: no error, and that client keeps the value it joined with. Populate these fields during onCreate(), before anyone is connected. If clients need to see a field change while they’re connected, it can’t be .fullStateOnly().
The field itself is not frozen. It stays an ordinary, mutable property on the server. Only its propagation to clients stops. On a streamed field the same rule applies per element. A client receives each element as it was when sent, and no later update to it.
Data Types
Primitive types
Each primitive type is declared through its t.* factory (or, in decorator style, by its quoted name):
import { schema, t } from "@colyseus/schema";
const Player = schema({
name: t.string(),
hp: t.uint8(),
score: t.number(),
}, "Player");The available primitive types:
string: utf8 string typeboolean:trueorfalsenumber: auto-detects the number type to use. (may use one extra byte when encoding)int8,int16,int32,int64: signed number types.uint8,uint16,uint32,uint64: unsigned number types.float32,float64: floating-point number types.bigint64,biguint64: unsigned / signed 64-bit bigint type.
Table of types and their limitations
| Type | Description | Limitation |
|---|---|---|
"string" | length-prefixed utf8 strings | maximum byte size of 4294967295 |
"number" | also known as “variable length encoding”. Auto-detects the number type to use. (may use one extra byte when encoding to identify the type) | ±1.7976931348623157e+308 (float64 limits) |
"boolean" | true or false | 0 or 1 |
"int64" and "uint64" | JavaScript numbers are 64 bit floats and thus cannot represent full 64 bit integers safely | The minimum/maximum integer that can be safely represented by float64 is -9007199254740991 to 9007199254740991 (53 bits of precision) |
Specialized number types:
| Type | Description | Limitation |
|---|---|---|
"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 (number type) | -2^53-1 (-9007199254740991) to 2^53-1 (9007199254740991) (safely) |
"uint64" | unsigned 64-bit integer (number type) | 0 to 2^53-1 (9007199254740991) (safely) |
"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 |
"bigint64" | signed 64-bit integer (bigint type) | -2^63 (-9223372036854775808) to 2^63-1 (9223372036854775807) |
"biguint64" | unsigned 64-bit integer (bigint type) | 0 to 2^64-1 (18446744073709551615) |
Quantized floats (t.quantized() / t.angle())
A float that changes every tick within a known range doesn’t need a full 4-byte float32. Think of a look angle, a normalized scalar, or a unit-vector component. t.quantized() maps the range onto an 8-, 16- or 32-bit unsigned integer on the wire, at a precision you choose:
const Input = schema({
yaw: t.angle(), // 2 bytes, wraps
pitch: t.quantized({ min: -Math.PI/2, max: Math.PI/2 }), // 2 bytes, clamps
throttle: t.quantized({ min: 0, max: 1, bits: 8 }), // 1 byte
}, "Input");Your code reads and writes a plain number. The conversion is invisible.
t.quantized() and t.angle() are builder-only: there is no decorator equivalent.
| Option | Default | Meaning |
|---|---|---|
min | (required) | Inclusive lower bound of the value domain |
max | (required) | Upper bound: inclusive when clamping, exclusive when wrapping |
bits | 16 | Wire width: 8, 16 or 32. Schema fields are byte-aligned, so only these three change the size |
mode | "clamp" | "clamp" or "wrap" (see below) |
Picking mode is the only real decision, and the wire size is identical either way:
"clamp": the value has hard walls. A pitch limit, a health bar, a 0–1 throttle. Both endpoints are exact and distinct; out-of-range input clamps to the nearest end. Looking past the pitch limit stops at the limit."wrap": the value is cyclic:minandmaxare the same point. An angle, a compass bearing, a hue, a phase. Any input is reduced modulo the range first, so a large accumulated or negative angle folds back in. A yaw keeps spinning instead of jamming.
Ask: does one step past the top land you back at the bottom, as the same physical thing? →
"wrap". Is the top a wall you can’t cross? →"clamp".
t.angle(opts?) is sugar for a full-circle wrapping angle in radians: t.quantized({ min: 0, max: 2π, mode: "wrap", bits: opts?.bits ?? 16 }). At the 16-bit default that’s ~0.0055° per step, finer than any mouse can resolve, in 2 bytes.
Choosing the wrong mode is a real bug. A clamped heading jams at the 0/2π seam. A wrapped pitch flips the camera the instant you look a hair too far up.
Quantization is lossy, but identically lossy on both peers. The field only ever yields the dequantized value, so a client’s prediction and the server’s simulation read the same number. A predicted hitscan ray matches the server’s. The loss is against the original float, never between the two peers.
For a cyclic field that you also interpolate on the client, "wrap" fixes the wire seam, not the rendering. Lerp it shortest-arc as well, or it unwinds the long way around.
Schema type
A Schema type can define properties as primitives, other Schema types, or collections (e.g., arrays or maps) that may contain nested types.
import { schema, t } from "@colyseus/schema";
const World = schema({
width: t.number(),
height: t.number(),
items: t.number().default(10),
}, "World");
const MyState = schema({
world: World, // shorthand for t.ref(World)
}, "MyState");The world field is auto-instantiated: new MyState().world is already a World instance. Use t.ref(World) instead of the bare class when you need to chain field modifiers.
A Schema type may hold up to 63 synchronizable properties. Nest a child Schema to go beyond that.
Inherited fields count toward the limit, so check the parent class too. So do .deprecated() fields, which keep their slot on purpose.
Array (ArraySchema)
The ArraySchema is a synchronizeable version of the built-in JavaScript Array type.
You can’t mix types inside ArraySchema.
import { schema, t } from "@colyseus/schema";
const MyState = schema({
animals: t.array("string"),
}, "MyState");At runtime the field is an ArraySchema instance: state.animals.push("cat") works right away.
array.push()
Adds one or more elements to the end of an array and returns the new length of the array.
const animals = new ArraySchema<string>();
animals.push("pigs", "goats");
animals.push("sheeps");
animals.push("cows");
// output: 4array.pop()
Removes the last element from an array and returns that element. This method changes the length of the array.
animals.pop();
// output: "cows"
animals.length
// output: 3array.shift()
Removes the first element from an array and returns that removed element. This method changes the length of the array.
animals.shift();
// output: "pigs"
animals.length
// output: 2array.unshift()
Adds one or more elements to the beginning of an array and returns the new length of the array.
animals.unshift("pigeon");
// output: 3array.indexOf()
Returns the first index at which a given element can be found in the array, or -1 if it is not present
const itemIndex = animals.indexOf("sheeps");array.splice()
Changes the contents of an array by removing or replacing existing elements and/or adding new elements in place.
// find the index of the item you'd like to remove
const itemIndex = animals.findIndex((animal) => animal === "sheeps");
// remove it!
animals.splice(itemIndex, 1);array.shuffle()
Shuffles the array in place. This method returns the shuffled array.
const animals = new ArraySchema<string>();
animals.push("pigs", "goats", "sheeps", "cows");
animals.shuffle();
// output: ["cows", "pigs", "sheeps", "goats"]array.move(cb)
Allows you to move elements from one index to another without re-encoding them. This method takes a callback function that should mutate the array in place.
state.cards.move((cards) => {
// swap items at index 2 and 3
[cards[3], cards[2]] = [cards[2], cards[3]];
})array.forEach()
Iterates over each element of the array.
this.state.array1 = new ArraySchema<string>('a', 'b', 'c');
this.state.array1.forEach(element => {
console.log(element);
});
// output: "a"
// output: "b"
// output: "c"More methods available for Array - Have a look at the MDN Documentation.
array.clear()
Empties the array. The frontend will trigger the onRemove callback for each element.
Map (MapSchema)
The MapSchema is a synchronizeable version of the built-in JavaScript Map type.
Maps are recommended to track your game entities by id, such as players, enemies, etc.
Only string keys are supported - Currently, the MapSchema only allows you to customize the value type. The key type is always string.
import { schema, t } from "@colyseus/schema";
const Player = schema({
x: t.number(),
y: t.number(),
}, "Player");
const MyState = schema({
players: t.map(Player),
}, "MyState");map.get()
Getting a map item by its key:
const map = new MapSchema<string>();
const item = map.get("key");map.set()
Setting a map item by key:
const map = new MapSchema<string>();
map.set("key", "value");map.delete()
Removes a map item by key:
map.delete("key");map.size
Return the number of elements in a MapSchema object.
const map = new MapSchema<number>();
map.set("one", 1);
map.set("two", 2);
console.log(map.size);
// output: 2map.forEach()
Iterates over each key/value pair of the map, in insertion order.
this.state.players.forEach((value, key) => {
console.log("key =>", key)
console.log("value =>", value)
});More methods available for Map - Have a look at the MDN Documentation.
map.clear()
Empties the Map. (Frontend will trigger onRemove for each element.)
Set (SetSchema)
SetSchema is only available for JavaScript SDK - Haxe, C# and Lua SDKs are not implemented.
The SetSchema is a synchronizeable version of the built-in JavaScript Set type.
The usage of SetSchema is very similar to [CollectionSchema], the biggest difference is that Sets hold unique values. Sets do not have a way to access a value directly. (like collection.at())
import { schema, t } from "@colyseus/schema";
const Effect = schema({
radius: t.number(),
}, "Effect");
const Player = schema({
effects: t.set(Effect),
}, "Player");set.add()
Appends an item to the SetSchema object.
const set = new SetSchema<number>();
set.add(1);
set.add(2);
set.add(3);set.delete()
Delete an item by its value.
set.delete(3);set.has()
Returns a boolean value whether an item exists in the Collection or not.
if (set.has(2)) {
console.log("Exists!");
} else {
console.log("Does not exist!");
}set.size
Return the number of elements in a SetSchema object.
const set = new SetSchema<number>();
set.add(10);
set.add(20);
set.add(30);
console.log(set.size);
// output: 3More methods available for Set - Have a look at the MDN Documentation.
set.clear()
Empties the Set. (Frontend will trigger onRemove for each element.)
CollectionSchema
CollectionSchema is only available for JavaScript SDK - Haxe, C#, Lua and C++ clients are not implemented.
The CollectionSchema works similarly as the ArraySchema, with the caveat that you don’t have control over its indexes.
import { schema, t } from "@colyseus/schema";
const Item = schema({
damage: t.number(),
}, "Item");
const Player = schema({
items: t.collection(Item),
}, "Player");collection.add()
Appends an item to the CollectionSchema object.
const collection = new CollectionSchema<number>();
collection.add(1);
collection.add(2);
collection.add(3);collection.at()
Gets an item at the specified index.
const collection = new CollectionSchema<string>();
collection.add("one");
collection.add("two");
collection.add("three");
collection.at(1);
// output: "two"collection.delete()
Delete an item by its value.
collection.delete("three");collection.has()
Returns a boolean value whether an item exists in the Collection or not.
if (collection.has("two")) {
console.log("Exists!");
} else {
console.log("Does not exist!");
}collection.size
Return the number of elements in a CollectionSchema object.
const collection = new CollectionSchema<number>();
collection.add(10);
collection.add(20);
collection.add(30);
console.log(collection.size);
// output: 3collection.forEach()
The forEach() method executes a provided function once per each index/value pair in the CollectionSchema object, in insertion order.
collection.forEach((value, at) => {
console.log("at =>", at)
console.log("value =>", value)
});collection.clear()
Empties the Collection. (Frontend will trigger onRemove for each element.)
Versioning and backwards/forwards compatibility
Backwards/forwards compatibility is possible by declaring new fields at the end of existing structures. Earlier declarations must not be removed. Mark them .deprecated() instead. See a versioning example below.
import { schema, t } from "@colyseus/schema";
const MyState = schema({
myField: t.string(),
}, "MyState");Accessing a .deprecated() field throws an error; use .deprecated(false) to keep reads allowed while the field is retired. (Decorator style: @deprecated() @type(...). See Deprecating fields.)
This modifier is particularly useful for native-compiled targets (C#, C++, Haxe), where the frontend may not have the latest schema definitions.
Inheritance support
The collection types (ArraySchema, MapSchema, etc) must hold items of the same type. They support inherited types from the same base instance.
These inherited types may define their own serialized fields.
The following example is supported:
import { schema, t } from "@colyseus/schema";
const Item = schema({/* base Item fields */}, "Item");
const Weapon = Item.extend({/* specialized Weapon fields */}, "Weapon");
const Shield = Item.extend({/* specialized Shield fields */}, "Shield");
const Inventory = schema({
items: t.map(Item),
}, "Inventory");
const inventory = new Inventory();
inventory.items.set("left", new Weapon());
inventory.items.set("right", new Shield());.extend() builds a real prototype chain (new Weapon() instanceof Item holds), inheriting fields, methods and defaults. Extended classes are registered with the serializer automatically: no extra registration step.