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

  • whenuserSuccess to joinroomAfter that, he will receive the full status from the server.
  • At the end of eachpatchRate, 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 sideonStateChange.
  • 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 extensionSchemaClass 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: CustomSchemaArray 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

  • eachSchemaThe structure can hold up to64A field. If you need more fields, use nestedSchemaStructure.
  • NaN ζˆ– nullThe numbers are encoded as0
  • nullThe string is encoded as""
  • InfinityAre encoded asNumber.MAX_SAFE_INTEGERThe Numbers.
  • Multidimensional arrays are not supported. Know how to use a one-dimensional array as a multidimensional array
  • Arrays ε’Œ MapsItems in must all be instances of the same type.
  • @colyseus/schemaEncode field values only in the order specified.
    • encoder(server) anddecoder(Client) must have the sameschemaDefinition.
    • 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) (on Schema 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: whenpropertyThe 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 ε’Œ onChangeWhat 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,LobbyRoomWill use thestateInstead.

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, extendSchemaEach class should have only field definitions.
    • Custom getters and setters can be implemented as long as there is no game logic in them.
  • Your game logic should be handled by other structures, such as:
    • Learn how to use command mode.
    • aEntity-ComponentSystem. We are currently short of aColyseuscompatibleECSPackage, some workAttempts have been made toECSY 与 @colyseus/schemacombining.

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