2. Hi, I’m Endel 👋
👨💻 Software Engineer and Game Developer from Brazil
🎓 Game Development & Digital Entertainment
GitHub / Twitter: @endel
I DON’T LIKE SOCCER
3. Summary
● Introduction
● Basic concepts
○ How Online Multiplayer Games Work
○ Authoritative Game Servers
● Colyseus in-depth
○ Match-making
○ Game State & Serialization
○ Infrastructure
● Client-side Techniques
4. A naive beginning (2015)
● Socket.io
● Not authoritative
● Synchronization issues between clients
5. Server
Broadcasts messages back
to clients
Client 1.
State
Client 2.
State
Client 3.
State
100ms 100ms 150ms
A naive beginning (2015)
Not authoritative
6. A naive beginning (2015)
All I wanted
● Be able to handle multiple game sessions
● Manipulate data structures in the server
○ Have them automatically synchronized with the clients
8. Why Colyseus?
● Multiplayer games should be easier to make
● Should be more accessible
● Should work on as many platforms as possible
● Should be open-source!
10. How Online Multiplayer Games Work?
● Peer-to-peer
● Client as a host
● Hybrid (of two above)
● Client / Server
Server
Dumb client Dumb client Dumb client
11. Responsibilities of the Server
● Hold the Game State
● Hold the Game Logic
● Validate client inputs
● Send the Game State to the clients
Server
Dumb client Dumb client Dumb client
12. Responsibilities of the Client
● Visual representation
● Apply game state updates in a pleasant manner
● Send inputs (actions) to the Server
Server
Dumb client Dumb client Dumb client
23. Colyseus
Game Rooms
The room is being
destroyed.
(It’s a good place to persist
things on the database)
Rooms are disposed
automatically when the last
client disconnects
(unless autoDispose=false)
26. Colyseus
Game Rooms
● Rooms are created during matchmaking
● A client can be connected to multiple rooms
● Each room connection has its own WebSocket connection
31. Full Room’s State
... State patches
Connection established
Colyseus
Room State & Serialization
... State patches
32. ● The Room State is MUTABLE
● Patches are broadcasted at every 50ms
○ Customizable via this.setPatchRate(ms)
( But first, a bit of a background… )
Colyseus
Room State & Serialization
33. 👎 Serialization: back in v0.1 ~ v0.3 👎
● Deep JavaScript Object Comparison
● JSON Patch (RFC6902)
● Patch size is too large
[
{ "op": "remove", "path": "/players/N150OHMve" },
{ "op": "add", "path": "/players/NkfeqSGPx", "value": {"x": 10, "y": 10} },
{ "op": "replace", "path": "/players/NkfeqSGPx/x", "value": 5 }
]
Previous State ⇔ Current State
34. 👎 Serialization: back in v0.4 ~ v0.9 👎
● “Fossil Delta” Binary Diff Compression
● Hard to detect a change in the state
● Creates a new copy when decoding
var currentState = this.state;
var currentStateEncoded = msgpack.encode( currentState );
// skip if state has not changed.
if ( currentStateEncoded.equals( this._previousStateEncoded ) ) {
return false;
}
var patches = delta.create(this._previousStateEncoded, currentStateEncoded);
CPU Intensive
37. 🔲 Encode/Decode only fields that have changed
🔲 No bottleneck to detect state changes
🔲 Mutations should be cheap
🔲 Avoid decoding large objects that haven't been patched
🔲 Better developer experience on statically-typed languages (C#, C++)
Serialization: v0.10+
Checklist
38. ✅ Encode/Decode only fields that have changed
✅ No bottleneck to detect state changes
❓ Mutations should be cheap (need more benchmarks, so far seems ✅)
✅ Avoid decoding large objects that haven't been patched
✅ Better developer experience on statically-typed languages (C#, C++)
Serialization: v0.10+
Checklist
39. class Player extends Schema {
@type("number") x: number;
@type("number") y: number;
}
const player = new Player();
player.x = 10;
player.y = 20;
player.encode()
[ 0, 10, 1, 20 ]
Definition Usage
Serialization: v0.10+
Demo
40. class Player extends Schema {
@type("number") x: number;
@type("number") y: number;
}
const player = new Player();
player.x = 10;
player.y = 20;
player.encode()
[ 0, 10, 1, 20 ]
Definition Usage
Serialization: v0.10+
Demo
41. class Player extends Schema {
@type("number") x: number;
@type("number") y: number;
}
const player = new Player();
player.x = 10;
player.y = 20;
player.encode()
[ 0, 10, 1, 20 ]
Definition Usage
Serialization: v0.10+
Demo
43. Colyseus
Room State & Serialization
import { Schema, type, MapSchema } from "@colyseus/schema";
class Player extends Schema {
@type("number")
position: number = 0;
}
class GameState extends Schema {
@type({ map: Player })
players = new MapSchema<Player>();
}
Game State Example
44. class MyRoom extends Room {
onCreate(options) {
this.setState(new GameState());
}
onJoin(client, options) {
this.state.players[client.sessionId] = new Player();
}
onMessage(client, message) {
if (message.move) {
this.state.players[client.sessionId].position++;
}
Colyseus
Room State & Serialization
Room State Example
52. ● By default, the entire state is sent to everyone
○ This way no “private” data can be stored in the state
● @filter() let you define a filter callback per property, per client
Colyseus
State Filters! (Experimental)
53. Colyseus
State Filters! (Experimental)
class Entity extends Schema {
@filter(function(client, value, root) {
// this = instance of Entity
// client = the client which this value is being filtered for
// value = `secret` value
// root = the Root State
return false;
})
@type("number") secret: number;
}
54. Colyseus
State Filters! (Experimental)
class Entity extends Schema {
@filter(function(client, value, root) {
// this = instance of Entity
// client = the client which this value is being filtered for
// value = `secret` value
// root = the Root State
return false;
})
@type("number") secret: number;
}
55. Colyseus
State Filters! (Experimental)
class Entity extends Schema {
@filter(function(client, value, root) {
// this = instance of Entity
// client = the client which this value is being filtered for
// value = `secret` value
// root = the Root State
return false;
})
@type("number") secret: number;
}
56. Colyseus
State Filters! (Experimental)
class Card extends Schema {
@filter(function(this: Card, client, value, root?: RootState) {
return root.
players[client.sessionId].
cards.indexOf(this) !== -1;
})
@type("number") number: number;
}
Card Game Example:
Only Card owners can see their own Card data
57. Colyseus
State Filters! (Experimental)
class Player extends Schema {
sessionId: string;
@filter(function(this: Player, client: any, value: Card) {
return this.sessionId === client.sessionId;
})
@type([Card]) cards = new ArraySchema<Card>();
}
⚠️ Array and Map filters are not currently supported ⚠️
(Planned for version 1.0)
58. Colyseus
State Filters! (Experimental)
class State extends Schema {
@filter(function(this: State, client: any, value: Player) {
const player = this.players[client.sessionId]
const a = value.x - player.x;
const b = value.y - player.y;
return (Math.sqrt(a * a + b * b)) <= 10;
})
@type({ map: Player }) players = new MapSchema<Player>();
}
⚠️ Array and Map filters are not currently supported ⚠️
(Planned for version 1.0)
59. using Colyseus.Schema;
public class State : Schema {
[Type("string")]
public string fieldString = "";
[Type("number")]
public float fieldNumber = 0;
[Type("ref", typeof(Player))]
public Player player = new Player(
[Type("array", typeof(ArraySchema<
public ArraySchema<Player> arrayOf
ArraySchema<Player>();
[Type("map", typeof(MapSchema<Play
Client-side integration (C#)
● npx schema-codegen
State.ts
--csharp
--output client-state
generated: State.cs
Colyseus
Room State & Serialization
60. using namespace colyseus::schema;
class State : public Schema {
public:
string fieldString = "";
varint_t fieldNumber = 0;
Player *player = new Player();
ArraySchema<Player*> *arrayOfPlaye
ArraySchema<Player*>();
MapSchema<Player*> *mapOfPlayers =
State() {
this->_indexes = {{0, "fiel
this->_types = {{0, "strin
this->_childPrimitiveTypes
Client-side integration (C++)
● npx schema-codegen
State.ts
--cpp
--output client-state
generated: State.hpp
Colyseus
Room State & Serialization
61. import io.colyseus.serializer.schema.Schema;
class State extends Schema {
@:type("string")
public var fieldString: String = ""
@:type("number")
public var fieldNumber: Dynamic = 0
@:type("ref", Player)
public var player: Player = new Pla
@:type("array", Player)
public var arrayOfPlayers: ArraySch
Client-side integration (Haxe)
● npx schema-codegen
State.ts
--haxe
--output client-state
generated: State.hx
Colyseus
Room State & Serialization
63. Colyseus
Sending Messages
● Messages use MsgPack by default
room.onMessage((message) => {
console.log("received:", message)
})
room.send({ action: "hello" })
Client-side
onMessage(client, message) {
if (message.action === "hello") {
this.broadcast("world!");
this.send(client, "world!");
}
}
Server-side
Send message to everyone
64. Colyseus
Sending Messages
● Messages use MsgPack by default
room.onMessage((message) => {
console.log("received:", message)
})
room.send({ action: "hello" })
Client-side
onMessage(client, message) {
if (message.action === "hello") {
this.broadcast("world!");
this.send(client, "world!");
}
}
Server-side
Send message to a single
client
65. Colyseus
Handling reconnection
● What if one of my clients drops the connection?
○ Closed/Refreshed Browser
○ Switched from Wifi / 3G
○ Unstable Internet Connection
○ Etc.
76. Client-side Techniques
● Delay to process actions
● Simulation on other client
happens 1 second later
https://poki.com/en/g/raft-wars-multiplayer
77. Client-side Techniques
● Client-side prediction
● Process player’s input immediately
● Enqueue player’s actions
in the server
● Process the queue at every
server tick
https://github.com/halftheopposite/tosios
78. Infrastructure
● How many CCU can Colyseus handle?
○ It depends!
● You have CPU and Memory limits
○ Optimize Your Game Loop
● 1000 clients in a single room?
○ Probably no!
● 1000 clients distributed across many rooms?
○ Probably yes!
86. ● Realtime
● Phaser/JavaScript
● Built by @tinydobbings
● ~350 concurrent players
Built with Colyseus
GunFight.io
https://gunfight.io
87. ● Turn-based
● JavaScript version
● Defold Engine version (@selimanac)
Built with Colyseus
Tic-Tac-Toe
https://github.com/endel/colyseus-tic-tac-toe
88. ● “The Open-Source IO Shooter”
● Realtime shooter
● PixiJS / TypeScript
● Built by @halftheopposite
Built with Colyseus
TOSIOS
https://github.com/halftheopposite/tosios
89. ● Turn-based
● Defold Engine / LUA
● ~250 concurrent players
● (soon on mobile!)
Built with Colyseus
Raft Wars Multiplayer
https://poki.com/en/g/raft-wars-multiplayer
90. ● Instant Game (Messenger)
● Realtime
Built with Colyseus
Chaos Shot
https://www.facebook.com/ChaosShot.io/
91. Plans for v1.0
● Better State Filters (on Arrays and Maps)
● Allow more sophisticated matchmaking
● Transport-independent (TCP/UDP)
● ...while keeping all client-side integrations up-to-date!
This game was made using Colyseus by @x100, a community member
This is how a room definition looks like
All these methods can be async
I’ll talk a bit about state serialization before going into handling the game state
I hope it’s not going to be too much information, because it’s kind of a deep topic
Fossil Delta is really good for small binary diffs
CPU Intensive detecting changes
Explain client-side new allocations and garbage collection
Why would you re-implement these great tools
Incremental encoding
This is the checklist I had before going ahead and implementing the serializer
Because we’re using Proxies to be able to catch changes on the state, mutations have this cost
The .encode() method is just to illustrate how it works, you usually don’t need to call it manually, as the room does automatically at the patch interval
The .encode() method is just to illustrate how it works, you usually don’t need to call it manually, as the room does automatically at the patch interval
The .encode() method is just to illustrate how it works, you usually don’t need to call it manually, as the room does automatically at the patch interval
Has been released early last year
I’ve a good chunk of time last year fixing bugs on it, and improving things
I’ll talk a bit about state serialization before going into handling the game state
During onStateChange, you can’t really know exactly what has changed
During onStateChange, you can’t really know exactly what has changed
Mention that “player” references can be passed around, as it is going to be always the same
Here you actually get autocompletion for the field, and the callback will have the right type you’re listening for
Filters are experimental, because their performance is not that great currently