Client-Server Communication
Introduction
Since the game is multiplayer, the game state needs to be synchronized between the server and the clients.
To prevent players from cheating, you'd normally want the server be the authority on the game state (authoritative servers), meaning the server decides what the game state is and the clients just receive updates from the server. Using this server-type the messages sent from each client also need to be validated. This is done by having the server run its own physics simulation and then comparing the results of that simulation with the results of the client's simulation.
Since there is a delay between the client sending a message and the server processing it and then sending the updated game state back to the clients, the client needs to do some sort of client side prediction to minimize the input lag.
The server's game state then needs to be synchronized with the client's predicted game state. The problem is that the server's game state will always be behind the client's predicted game state. This is where server reconciliation is used. This adjusts the client's game state to match the server's game state, taking into account the delay between the client's latest state and the server's latest state.
In addition, we need to make the movement of other players and objects look smooth on the client side. This is done on the client side using time interpolation which in short calculates states between two game states received from the server.
Since this is just a short overview, I recommend reading Gabriel Gambetta's article on this topic and Valve's overview on client-server communication.
Coming back to Phaser, there are a few limitations that make it hard to implement a client-server architecture as described above:
- Phaser does not have a built-in way to run arcade physics on the server without rendering the game.
- Matter.js, another physics engine supported by Phaser, could run standalone on the server but is not deterministic, which means that the server's physics simulation would always be out of sync with the client's physics simulation.
- Phaser does not have built-in support for client side prediction, server reconciliation, time interpolation, lag compensation etc., meaning we would have to implement all of this ourselves.
As a compromise, we decided to run the physics simulation on the clients and share the game state with the server, which then broadcasts the game state to all clients. Each client is responsible for updating the game state of the entities that it owns. The ownership of entities is character-dependent and is determined by the client that controls the character. For more information on which entities are owned by which client, see the Entity Types and Ownership section.
Overview
As mentioned in the previous section, each client is responsible for updating the game state of the entities that it owns. The client sends state updates to the server, which are then broadcast to all other clients.
Since physics are calculated on the client side, the server does not run a physics simulation and therefore cannot validate the messages that it receives from the clients.
This means that clients can cheat by sending invalid messages to the server. However, since our game is not competitive, cheating only affects the cheater's and their partner's game experience.
For more information on how the clients update the servers game state, see the client state update message section.
Entity Types
In context of client-server communication, there are three types of entities. Depending on the entity type different rules on how and who can update the entity's state are applied.
Dynamic Entities
Dynamic entities are game objects with a certain functionality that can move (players, boxes or enemies). This type of entity must be owned by a client. The client is responsible for calculating physics for entities he owns. The game state of this entity can only be updated by the client that owns it.
Only entities that are owned by the same client can collide with each other, because only these entities are in the same physics world.
Example: Moot and Major Dom cannot collide with each other, because Moot's and Major Dom's physics are rendered by different clients. Boxes are owned by the client that controls Moot, which means that boxes can collide with Moot but not with Major Dom.
Static Entities
Static entities are game objects with a certain functionality that don't move (e.g. pressure plates). This type of entity can be updated by any client, since they don't move and don't need a dynamic physics simulation.
This type of entity is part of the physics world of all clients, which means that all clients can collide with these entities.
Server Entities
Server entities are game objects that are processed by the server based on events or other entities (e.g. doors).
This type of entity is part of the physics world of all clients, which means that all clients can collide with these entities. However, only the server can update the game state of these entities.
Ownership
As mentioned before, entities are owned either by the server or by one or both clients.
| Entity Type | Owner | Type | Description |
|---|---|---|---|
| Player | Moot or MajorDom (every client controls its own character) | Dynamic | |
| Box | Moot | Dynamic | boxes are affected by gravity, just like Moot, which means that these entities must collide with each other. |
| Pressure Plate | Both Clients | Static | pressure plates can be triggered by any client, because there is no case, where both clients update this entity at the same time. |
| Door | Server | Server | Triggered by pressure plates; processed by server |
| Collectible | Both Clients | Static | items are not affected by gravity |
| Breakable Floor | Both Clients | Static | floors that can be destroyed by players or boxes |
| Storyteller | Both Clients | Static | The storyteller is not affected by gravity. |
Client State Updates
By default, only the Colyseus server can update the game state but not the clients. Internally Colyseus uses a separate Schema package to define, encode and decode the game state.
Colyseus schemas have two main methods:
encodewhich encodes the schema changes into a byte arraydecodewhich decodes the byte array back into the schema
This package can also be used standalone which allows us to make changes to the game state on the client side and send it back to the server as a delta encoded message. The server then applies the deltas to the game state and broadcasts the updated game state to all clients.
Using this technique, there are a few things to consider:
- Clients should only be able to update the entity states of entities that they own. Otherwise, clients could override the game state of entities that they don't own.
- This technique is a little bit inefficient because state updates sent by the clients are sent back to same client even if the client already knows the state.
Entity state messages are encoded as follows:
- Each entity that has been updated is encoded as a delta message.
- To the end of the byte array, the entity's unique ID is added.
- All entity updates from the same entity type are grouped together in an array.
- The entity type arrays are then grouped together in a single payload. If there are no updates for a certain entity type, an empty array must be sent.
- The payload is then sent to the server via the State Update Message.
The order of the entity type arrays is as follows:
- players
- boxes
- pressure plates
- collectibles
- breakable floors
- storytellers
Example
// Step 1: Encode updates for each entity that needs to be updated
// Each update consists of encoded data followed by the entity's unique ID
const player1Update = [encodedData, "1"]
const player2Update = [encodedData, "2"]
const box1Update = [encodedData, "1"]
const pressurePlate1Update = [encodedData, "1"]
const pressurePlate2Update = [encodedData, "2"]
// no collectible updates
const breakableFloor1Update = [encodedData, "1"]
const breakableFloor2Update = [encodedData, "1"]
// no storyteller updates
// Step 2: Group updates by entity type into separate arrays
const playerUpdates = [player1Update, player2Update]
const boxUpdates = [box1Update]
const pressurePlateUpdates = [pressurePlate1Update, pressurePlate2Update]
// no collectible updates
const collectibleUpdates = []
const breakableFloorUpdates = [breakableFloor1Update, breakableFloor2Update]
// no storyteller updates
const storytellerUpdates = []
// Step 3: Bundle all entity type updates into a single payload
// The order of the entity type arrays must always be the same
const updatePayload = [playerUpdates, boxUpdates, pressurePlateUpdates, collectibleUpdates, breakableFloorUpdates, storytellerUpdates]
// Step 4: Send the update payload to the server if there are any updates
if (playerUpdates.length > 0 || boxUpdates.length > 0 || pressurePlateUpdates.length > 0 || collectibleUpdates.length > 0 || breakableFloorUpdates.length > 0 || storytellerUpdates.length > 0) {
server.send(ClientMessageCodes.StateUpdate, updatePayload)
}Update Messages
Both the server and the clients send messages to each triggering events and updating the game state. Note that the clients can only send messages to the server and not directly to other clients.
Client Update Messages
These messages are sent from the client to the server.
State Update
This message type is used to update the state of certain entities by sending a payload of entity updates.
Toggle Ready Vote
This message type is used to toggle the ready state of the client. It can ony be sent if the game status is LevelSelection. If all clients are ready, the server will start the game.
Switch Characters
This message type is used to switch the characters of the clients. It can only be sent if the game status is LevelSelection.
Select Level
This message type is used to select a level. It can only be sent if the game status is LevelSelection. Following data is sent with this message type:
Leave Level
This message type is used to leave the current level. It can only be sent if the game status is LevelRunning. When this message is received, the server will end the current level.
Request Map Data
This message type is used to request the current map data from the server.
Level Loaded
This message type is used to signal the server that the client has loaded the level and is ready to start the game.
Play Sounds
This message type is the counterpart of the server message and is used to share sound events with the sever. The server will then broadcast the sound event to all other clients.
Server Update Messages
Server update messages are sent from the server to one or more clients. Currently, there are following message types:
Map Data
This message type is used to send the current map data to the client when a client requests it. This message type contains a tiled map object. Read the levels section for more information on how a level is structured.
Game Over
This message type signals to the clients that the game is over a few seconds before the game status changes to LevelSelection. This gives the clients some time to transition to the game over screen while showing the game in the background.
This message type contains the following data:
Play Sounds
This message type is the counterpart of the client message and is used to share sound events received from a client with all other clients.
State Update
Internally, colyseus sends the game state to all clients at a fixed rate, which is currently set to 32 milliseconds.
Level Start
Changing the game status to StartingLevel indicates that the level is about to start. When clients detect this change, the following sequence of events is triggered:
The similar sequence of events is triggered when a client joins a running level. The only difference is that the server does not wait until all the clients have loaded the level.