Get started with multiplayer server development. In the future, we will update articles about K8S operation and maintenance, large-scale and rapid expansion of dedicated game servers based on Google Agones. Embrace βοΈ Native π€ cloud-native!
A series of
- ColyseusJS Lightweight Multiplayer Server Development Framework – Chinese Manual (PART 1)
- ColyseusJS Lightweight Multiplayer Server Development Framework – Chinese Manual (Chinese)
State handling
In Colyseus, room Handlers were stateful. Each room has its own state. State changes are automatically synchronized to all connected clients.
Serialization method
Schema
(default)
State synchronization
- when
user
Success to joinroom
After that, he will receive the full status from the server. - At the end of each
patchRate
, a binary patch of the status is sent to each client (default is50ms
) - After receiving each patch from the server, it is called on the client side
onStateChange
. - Each serialization method has its own special way of handling incoming state patches.
Schema
SchemaSerializer was introduced from Colyseus 0.10 and is the default serialization method.
The Schema structure is only used for the state of the room (synchronous data). You do not need to use Schema and other structures for data in algorithms that cannot be synchronized.
The service side
To use SchemaSerializer, you must:
- There’s an extension
Schema
Class state class - with
@type()
Decorators annotate all of your syncable properties - Instantiate state for your room (
this.setState(new MyState())
)
import { Schema, type } from "@colyseus/schema";
class MyState extends Schema {
@type("string")
currentTurn: string;
}
Copy the code
The original type
These are the types and limitations you can provide for the @type() decorator.
If you know exactly the scope of the number attribute, you can optimize serialization by providing it with the right primitive type. Otherwise, use “number”, which adds an extra byte to identify itself during serialization.
Type | Description | Limitation |
---|---|---|
"string" |
utf8 strings | maximum byte size of 4294967295 |
"number" |
auto-detects the int or float type to be used. (adds an 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.40282347 e to 3.40282347 e |
"float64" |
double-precision floating-point number | 1.7976931348623157 e+308 to 1.7976931348623157 e+308 |
The child schema attribute
You can define more custom data types, such as direct reference, map, or array, in the “root” state definition.
import { Schema, type } from "@colyseus/schema";
class World extends Schema {
@type("number")
width: number;
@type("number")
height: number;
@type("number")
items: number = 10;
}
class MyState extends Schema {
@type(World)
world: World = new World();
}
Copy the code
ArraySchema
ArraySchema is a synchronizable version of the built-in JavaScript Array type.
You can use more methods from arrays. Look at the MDN document for the array.
Example: CustomSchema
Array of type
import { Schema, ArraySchema, type } from "@colyseus/schema";
class Block extends Schema {
@type("number")
x: number;
@type("number")
y: number;
}
class MyState extends Schema {
@type([ Block ])
blocks = new ArraySchema<Block>();
}
Copy the code
Example: An array of primitive types
You cannot mix types in arrays.
import { Schema, ArraySchema, type } from "@colyseus/schema";
class MyState extends Schema {
@type([ "string" ])
animals = new ArraySchema<string> (); }Copy the code
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: 4
Copy the code
array.pop()
Removes the last element from the array and returns it. This method changes the length of the array.
animals.pop();
// output: "cows"
animals.length
// output: 3
Copy the code
array.shift()
Removes the first element from the array and returns the deleted element. This method changes the length of the array.
animals.shift();
// output: "pigs"
animals.length
// output: 2
Copy the code
array.unshift()
Adds one or more elements to the beginning of an array and returns the new length of the array.
animals.unshift("pigeon");
// output: 3
Copy the code
array.indexOf()
Returns the first index of the given element in the array, or -1 if none exists
const itemIndex = animals.indexOf("sheeps");
Copy the code
array.splice()
Change the contents of an array by deleting 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);
Copy the code
array.forEach()
Iterate over each element in 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"
Copy the code
MapSchema
MapSchema is a synchronizable version of the built-in JavaScript Map type.
It is recommended to use Maps to track your game entities by ID, such as players, enemies, etc.
“Currently only string keys are supported” : currently, MapSchema only allows you to provide value types. The key type is always string.
import { Schema, MapSchema, type } from "@colyseus/schema";
class Player extends Schema {
@type("number")
x: number;
@type("number")
y: number;
}
class MyState extends Schema {
@type({ map: Player })
players = new MapSchema<Player>();
}
Copy the code
map.get()
Get a map entry by key:
const map = new MapSchema<string> ();const item = map.get("key");
Copy the code
OR
//
// NOT RECOMMENDED
//
// This is a compatibility layer with previous versions of @colyseus/schema
// This is going to be deprecated in the future.
//
const item = map["key"];
Copy the code
map.set()
Press key to set the map item:
const map = new MapSchema<string> (); map.set("key"."value");
Copy the code
OR
//
// NOT RECOMMENDED
//
// This is a compatibility layer with previous versions of @colyseus/schema
// This is going to be deprecated in the future.
//
map["key"] = "value";
Copy the code
map.delete()
Delete a map entry by pressing key:
map.delete("key");
Copy the code
OR
//
// NOT RECOMMENDED
//
// This is a compatibility layer with previous versions of @colyseus/schema
// This is going to be deprecated in the future.
//
delete map["key"];
Copy the code
map.size
Returns 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: 2
Copy the code
map.forEach()
Each key/value pair of the map is traversed in insertion order.
this.state.players.forEach((value, key) = > {
console.log("key =>", key)
console.log("value =>", value)
});
Copy the code
“All Map methods” : You can use more methods from Maps. Take a look at Maps in the MDN document.
CollectionSchema
“CollectionSchema is implemented in JavaScript only “: Currently, CollectionSchema is only available in JavaScript. Haxe, c#, LUA and c++ clients are not currently supported.
CollectionSchema works like ArraySchema, but it’s important to note that you have no control over its indexes.
import { Schema, CollectionSchema, type } from "@colyseus/schema";
class Item extends Schema {
@type("number")
damage: number;
}
class Player extends Schema {
@type({ collection: Item })
items = new CollectionSchema<Item>();
}
Copy the code
collection.add()
Append item to the CollectionSchema object.
const collection = new CollectionSchema<number> (); collection.add(1);
collection.add(2);
collection.add(3);
Copy the code
collection.at()
Gets the item at the specified index.
const collection = new CollectionSchema<string> (); collection.add("one");
collection.add("two");
collection.add("three");
collection.at(1);
// output: "two"
Copy the code
collection.delete()
Deletes an item based on its value.
collection.delete("three");
Copy the code
collection.has()
Returns a Boolean, regardless of whether the item is present in the set.
if (collection.has("two")) {
console.log("Exists!");
} else {
console.log("Does not exist!");
}
Copy the code
collection.size
Returns the number of elements in the CollectionSchema object.
const collection = new CollectionSchema<number> (); collection.add(10);
collection.add(20);
collection.add(30);
console.log(collection.size);
// output: 3
Copy the code
collection.forEach()
ForEach index/value pair in the CollectionSchema object, the forEach() method executes the provided functions once, in insertion order.
collection.forEach((value, at) = > {
console.log("at =>", at)
console.log("value =>", value)
});
Copy the code
SetSchema
“SetSchema is implemented in JavaScript only “: SetSchema is currently only available in JavaScript. Haxe, C#, LUA and C++ clients are not currently supported.
SetSchema is a synchronizable version of the built-in JavaScript Set type.
“More” : You can use more methods from Sets. Take a look at the MDN document Sets.
The usage of SetSchema is very similar to that of CollectionSchema. The main difference is that Sets have unique values. Sets have no direct access to values. (such as a collection. The at ())
import { Schema, SetSchema, type } from "@colyseus/schema";
class Effect extends Schema {
@type("number")
radius: number;
}
class Player extends Schema {
@type({ set: Effect })
effects = new SetSchema<Effect>();
}
Copy the code
set.add()
Append an item to the SetSchema object.
const set = new CollectionSchema<number> (); set.add(1);
set.add(2);
set.add(3);
Copy the code
set.at()
Gets the item at the specified index.
const set = new CollectionSchema<string> (); set.add("one");
set.add("two");
set.add("three");
set.at(1);
// output: "two"
Copy the code
set.delete()
Deletes an item based on its value.
set.delete("three");
Copy the code
set.has()
Returns a Boolean value, whether or not the item exists in the collection.
if (set.has("two")) {
console.log("Exists!");
} else {
console.log("Does not exist!");
}
Copy the code
set.size
Returns 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: 3
Copy the code
Filter data for each client
“This feature is experimental” : @filter()/@filterChildren() is experimental and may not be optimized for fast paced games.
Filtering is designed to hide certain parts of the state for a particular client to avoid cheating in case the player decides to check the data from the network and see the unfiltered state information.
Data filters are callbacks that are triggered by each client and each field (or, in the case of @FilterChildren). If the filter callback returns true, the field data will be sent for that particular client; otherwise, the data will not be sent for that client.
Note that the filter function does not automatically restart if its dependencies change, but only if the filter field (or its subfields) is updated. See this problem for a resolution.
@filter()
property decorator
The @filter() attribute decorator can be used to filter out entire Schema fields.
Here’s what the @filter() signature looks like:
class State extends Schema {
@filter(function(client, value, root) {
// client is:
//
// the current client that's going to receive this data. you may use its
// client.sessionId, or other information to decide whether this value is
// going to be synched or not.
// value is:
// the value of the field @filter() is being applied to
// root is:
// the root instance of your room state. you may use it to access other
// structures in the process of decision whether this value is going to be
// synched or not.
})
@type("string") field: string;
}
Copy the code
@filterChildren()
Attribute decorator
The @FilterChildren () property decorator can be used to filter out the internal items of arrays, maps, sets, and more. Its signature is very similar to @filter(), except that the key parameter is added before value — representing each of the items in ArraySchema, MapSchema, CollectionSchema, and so on.
class State extends Schema {
@filterChildren(function(client, key, value, root) {
// client is:
//
// the current client that's going to receive this data. you may use its
// client.sessionId, or other information to decide whether this value is
// going to be synched or not.
// key is:
// the key of the current value inside the structure
// value is:
// the current value inside the structure
// root is:
// the root instance of your room state. you may use it to access other
// structures in the process of decision whether this value is going to be
// synched or not.
})
@type([Cards]) cards = new ArraySchema<Card>();
}
Copy the code
Example: In a card game, information about each card should only be used by the card owner, or under certain circumstances (such as when the card has been discarded).
Check the @filter() callback signature:
import { Client } from "colyseus";
class Card extends Schema {
@type("string") owner: string; // contains the sessionId of Card owner
@type("boolean") discarded: boolean = false;
/**
* DO NOT USE ARROW FUNCTION INSIDE `@filter`
* (IT WILL FORCE A DIFFERENT `this` SCOPE)
*/
@filter(function(
this: Card, // the instance of the class `@filter` has been defined (instance of `Card`)
client: Client, // the Room's `client` instance which this data is going to be filtered to
value: Card['number'].// the value of the field to be filtered. (value of `number` field)
root: Schema // the root state Schema instance
) {
return this.discarded || this.owner === client.sessionId;
})
@type("uint8") number: number;
}
Copy the code
Backward/forward compatibility
Backward/forward compatibility can be achieved by declaring a new field at the end of an existing structure, where the previous declaration is not removed but is marked @deprecated() when needed.
This is especially useful for natively compiled targets such as C#, C++, Haxe, etc. – where the client may not have the latest version of the schema definition.
Limitations and best practices
- each
Schema
The structure can hold up to64
A field. If you need more fields, use nestedSchema
Structure. NaN
ζnull
The numbers are encoded as0
null
The string is encoded as""
Infinity
Are encoded asNumber.MAX_SAFE_INTEGER
The Numbers.- Multidimensional arrays are not supported. Know how to use a one-dimensional array as a multidimensional array
Arrays
εMaps
Items in must all be instances of the same type.@colyseus/schema
Encode field values only in the order specified.encoder
(server) anddecoder
(Client) must have the sameschema
Definition.- The fields must be in the same order.
The client
Callbacks
You can use the following callbacks in the client Schema structure to handle changes from the server side.
onAdd (instance, key)
onRemove (instance, key)
onChange (changes)
(onSchema
instance)onChange (instance, key)
(on collections:MapSchema
.ArraySchema
, etc.)listen()
“C#, C++, Haxe” : when using statically typed languages, generate client schema files based on TypeScript schema definitions. See Generating a Schema on the client side.
onAdd (instance, key)
The onAdd callback can only be used with maps (MapSchema) and arrays (ArraySchema). The onAdd callback is called with the added instance and the key on the Holder object as arguments.
room.state.players.onAdd = (player, key) = > {
console.log(player, "has been added at", key);
// add your player entity to the game world!
// If you want to track changes on a child object inside a map, this is a common pattern:
player.onChange = function(changes) {
changes.forEach(change= > {
console.log(change.field);
console.log(change.value);
console.log(change.previousValue); })};// force "onChange" to be called immediatelly
player.triggerAll();
};
Copy the code
onRemove (instance, key)
The onRemove callback can only be used with maps (MapSchema) and arrays (ArraySchema). The onRemove callback is called with the removed instance and its key on the Holder object as arguments.
room.state.players.onRemove = (player, key) = > {
console.log(player, "has been removed at", key);
// remove your player entity from the game world!
};
Copy the code
onChange (changes: DataChange[])
OnChange works differently for direct Schema references and collection structures. For onChange on collection structures (array, Map, etc.), click here
You can register onChange to track Schema instance property changes. The onChange callback is triggered by a set of changed properties and previous values.
room.state.onChange = (changes) = > {
changes.forEach(change= > {
console.log(change.field);
console.log(change.value);
console.log(change.previousValue);
});
};
Copy the code
You cannot register onChange callbacks on objects that are not synchronized with the client.
onChange (instance, key)
OnChange works differently for direct Schema references and collection Structures.
This callback is triggered whenever a collection of primitive types (String, Number, Boolean, etc.) updates some of its values.
room.state.players.onChange = (player, key) = > {
console.log(player, "have changes at", key);
};
Copy the code
If you want to detect changes in a collection of non-primitive types (containing Schema instances), use onAdd and register onChange on them.
“OnChange, onAdd, and onRemove are exclusive” : onAdd or onRemove does not trigger an onChange callback.
If you still need to detect changes during these steps, consider registering 'onAdd' and 'onRemove'.Copy the code
.listen(prop, callback)
Listen for individual property changes.
.listen() currently only works with JavaScript/TypeScript.
Parameters:
property
: The name of the property you want to listen for changes.callback
: whenproperty
The callback that will be triggered when it changes.
state.listen("currentTurn".(currentValue, previousValue) = > {
console.log(`currentTurn is now ${currentValue}`);
console.log(`previous value was: ${previousValue}`);
});
Copy the code
The.listen() method returns a function to unregister listeners:
const removeListener = state.listen("currentTurn".(currentValue, previousValue) = > {
// ...
});
// later on, if you don't need the listener anymore, you can call `removeListener()` to stop listening for `"currentTurn"` changes.
removeListener();
Copy the code
listen
ε onChange
What is the difference between?
The.listen() method is short for onChange on a single property. The following is
state.onChange = function(changes) {
changes.forEach((change) = > {
if (change.field === "currentTurn") {
console.log(`currentTurn is now ${change.value}`);
console.log(`previous value was: ${change.previousValue}`); }})}Copy the code
Client schema generation
This only works if you use statically typed languages such as C#, C++, or Haxe.
In a server project, you can run NPX Schema-codeGen to automatically generate client schema files.
npx schema-codegen --help
Copy the code
Output:
schema-codegen [path/to/Schema.ts]
Usage (C#/Unity)
schema-codegen src/Schema.ts --output client-side/ --csharp --namespace MyGame.Schema
Valid options:
--output: fhe output directory for generated client-side schema files
--csharp: generate for C#/Unity
--cpp: generate for C++
--haxe: generate for Haxe
--ts: generate for TypeScript
--js: generate for JavaScript
--java: generate for Java
Optional:
--namespace: generate namespace on output code
Copy the code
Built-in room Β» Lobby Room
“Lobby room client API will change on Colyseus 1.0.0” :
- Built-in lobby rooms currently rely on sending messages to notify customers of available rooms. when
@filter()
When it becomes stable,LobbyRoom
Will use thestate
Instead.
The server side
The built-in LobbyRoom automatically notifies its connected clients whenever there is an update to the room “realtime listing”.
import { LobbyRoom } from "colyseus";
// Expose the "lobby" room.
gameServer
.define("lobby", LobbyRoom);
// Expose your game room with realtime listing enabled.
gameServer
.define("your_game", YourGameRoom)
.enableRealtimeListing();
Copy the code
LobbyRoom is automatically notified during onCreate(), onJoin(), onLeave(), and onDispose().
If you have updated your room’s metadata and need to trigger a lobby update, you can call updateLobby() after the metadata update:
import { Room, updateLobby } from "colyseus";
class YourGameRoom extends Room {
onCreate() {
//
// This is just a demonstration
// on how to call `updateLobby` from your Room
//
this.clock.setTimeout(() = > {
this.setMetadata({
customData: "Hello world!"
}).then(() = > updateLobby(this));
}, 5000); }}Copy the code
The client
You need to keep track of rooms being added, deleted, and updated with information sent to clients from the LobbyRoom.
import { Client, RoomAvailable } from "colyseus.js";
const client = new Client("ws://localhost:2567");
const lobby = await client.joinOrCreate("lobby");
let allRooms: RoomAvailable[] = [];
lobby.onMessage("rooms".(rooms) = > {
allRooms = rooms;
});
lobby.onMessage("+".([roomId, room]) = > {
const roomIndex = allRooms.findIndex((room) = > room.roomId === roomId);
if(roomIndex ! = = -1) {
allRooms[roomIndex] = room;
} else{ allRooms.push(room); }}); lobby.onMessage("-".(roomId) = > {
allRooms = allRooms.filter((room) = >room.roomId ! == roomId); });Copy the code
Built-in room Β» Relay Room
The built-in RelayRoom is useful for simple use cases where you don’t need to save any state on the server side except for the clients connected to it.
By simply relaying the message (forwarding the message from the client to everyone else) – the server cannot validate any message – the client should perform validation.
RelayRoom’s source code is very simple. The general recommendation is to implement your own version using server-side validation when you see fit.
The server side
import { RelayRoom } from "colyseus";
// Expose your relayed room
gameServer.define("your_relayed_room", RelayRoom, {
maxClients: 4.allowReconnectionTime: 120
});
Copy the code
The client
See how to register callbacks for players joining, leaving, sending, and receiving messages from the Relayed Room.
Connect to a room
import { Client } from "colyseus.js";
const client = new Client("ws://localhost:2567");
//
// Join the relayed room
//
const relay = await client.joinOrCreate("your_relayed_room", {
name: "This is my name!"
});
Copy the code
Register callbacks when players join and leave
//
// Detect when a player joined the room
//
relay.state.players.onAdd = (player, sessionId) = > {
if (relay.sessionId === sessionId) {
console.log("It's me!", player.name);
} else {
console.log("It's an opponent", player.name, sessionId); }}//
// Detect when a player leave the room
//
relay.state.players.onRemove = (player, sessionId) = > {
console.log("Opponent left!", player, sessionId);
}
//
// Detect when the connectivity of a player has changed
// (only available if you provided `allowReconnection: true` in the server-side)
//
relay.state.players.onChange = (player, sessionId) = > {
if (player.connected) {
console.log("Opponent has reconnected!", player, sessionId);
} else {
console.log("Opponent has disconnected!", player, sessionId); }}Copy the code
Send and receive messages
//
// By sending a message, all other clients will receive it under the same name
// Messages are only sent to other connected clients, never the current one.
//
relay.send("fire", {
x: 100.y: 200
});
//
// Register a callback for messages you're interested in from other clients.
//
relay.onMessage("fire".([sessionId, message]) = > {
//
// The `sessionId` from who sent the message
//
console.log(sessionId, "sent a message!");
//
// The actual message sent by the other client
//
console.log("fire at", message);
});
Copy the code
Best practices from Colyseus
This section needs improvement and more examples! Each paragraph needs its own page, with detailed examples and better explanations.
- Keep your room class as small as possible with no game logic
- Keep the data structures that can be synchronized as small as possible
- Ideally, extend
Schema
Each class should have only field definitions. - Custom getters and setters can be implemented as long as there is no game logic in them.
- Ideally, extend
- Your game logic should be handled by other structures, such as:
- Learn how to use command mode.
- a
Entity-Component
System. We are currently short of aColyseus
compatibleECS
Package, some workAttempts have been made toECSY
δΈ@colyseus/schema
combining.
Why is that?
- Models (@colyseus/ Schema) should only contain data, not game logic.
- Rooms should have as little code as possible and forward action to other structures
The command mode has several advantages, such as:
- It decouples the class that invokes the operation from the object that knows how to perform it.
- It allows you to create a sequence of commands by providing a queue system.
- Implementing extensions to add a new command is easy and can be done without changing existing code.
- Strictly control how and when commands are invoked.
- Because commands simplify the code, it is easier to use, understand, and test.
usage
The installation
npm install --save @colyseus/command
Copy the code
Initialize dispatcher in your room implementation:
import { Room } from "colyseus";
import { Dispatcher } from "@colyseus/command";
import { OnJoinCommand } from "./OnJoinCommand";
class MyRoom extends Room<YourState> {
dispatcher = new Dispatcher(this);
onCreate() {
this.setState(new YourState());
}
onJoin(client, options) {
this.dispatcher.dispatch(new OnJoinCommand(), {
sessionId: client.sessionId
});
}
onDispose() {
this.dispatcher.stop(); }}Copy the code
const colyseus = require("colyseus");
const command = require("@colyseus/command");
const OnJoinCommand = require("./OnJoinCommand");
class MyRoom extends colyseus.Room {
onCreate() {
this.dispatcher = new command.Dispatcher(this);
this.setState(new YourState());
}
onJoin(client, options) {
this.dispatcher.dispatch(new OnJoinCommand(), {
sessionId: client.sessionId
});
}
onDispose() {
this.dispatcher.stop(); }}Copy the code
The command implementation looks like this:
// OnJoinCommand.ts
import { Command } from "@colyseus/command";
export class OnJoinCommand extends Command<YourState.{
sessionId: string} > {execute({ sessionId }) {
this.state.players[sessionId] = newPlayer(); }}Copy the code
// OnJoinCommand.js
const command = require("@colyseus/command");
exports.OnJoinCommand = class OnJoinCommand extends command.Command {
execute({ sessionId }) {
this.state.players[sessionId] = newPlayer(); }}Copy the code
To view more
- See Command Definition
- Refer to the usage
- Refer to the implementation
Refs
The Chinese manual is updated at:
- https:/colyseus.hacker-linner.com
I am weishao wechat: uuhells123 public number: hackers afternoon tea add my wechat (mutual learning exchange), pay attention to the public number (for more learning materials ~)Copy the code