How I Created an Online Multiplayer Game Using Colyseus
You can find the source code for the Colyseus Server and the Vue.js
The Challenge
Let's delve into the world of Trivia Games, where players engage in competitive quizzes, testing their knowledge. This article aims to explore the process of creating a browser-based multiplayer trivia game using Colyseus.
Designing the Game Logic
Before diving into the code, I began by sketching flowcharts to visualize the game's logic, such as player room allocation and question distribution.
Picture here
The Concept of (Game) Rooms
The concept of rooms is not a new one, and it's not limited to multiplayer games. Many online experiences, such as video conference meetings or group chats, follow a room-based pattern. In the context of our game, rooms provide a virtual space for players to join and play with their friends.
Rooms is a paradigm that can helps isolate each game session into its own room.
Initially, a player creates a room and then invites the other players to join.
The First Prototype: Exploring Ably
In the initial stages of development, I opted to use Ably as the communication platform. Ably offers a generous free plan (6 million messages per month) and has the ability to minimize latency by players joining from different parts of the world. In a traditional server-client model, players would connect to a common server, potentially leading to latency issues. However, Ably's infrastructure can help mitigate this problem.
Ably follows the Pub-Sub (Publish-Subscribe) pattern, where messages are published to a channel and subscribers receive those messages. This pattern allows for real-time communication, making it suitable for multiplayer game development.
The Publish-Subscribe pattern
Designing the prototype
But going with this route means that I will have to implement the creation of
- rooms, managing authentication
- synchronization, state sharing (when an event is emitted the server process it and mutates the state, all clients need to get the latest state)
- client room creating/joining
- messaging format e.g. when a player respond to a question a message like the following needs to be sent
{"event_name":"player_answer_question", "data":{"player_response":1,"player_id":"foo"}}
- threading (!) to handle multiple rooms at the same time
But nevertheless, I continued anyway to explore this approach.
I had also designed some flow charts of different scenarios (Room creation, game flow etc):
Thankfully, Ably provides an elegant Dashboard, and a developer menu that I can see all the messages and join a channel (room).
For the prototype I used Python.
The Prototype Code
The code snippet you see here is a prototype for creating and joining rooms in a multiplayer game. It's a starting point for testing the concept, even though it might not be the most elegant solution.
In this prototype, we create new rooms and spawn instances to manage them. When a message is received, we check if it's a 'wait' message, and if so, we introduce a delay. This represents a basic form of game logic where timing is essential.
However, this approach requires writing a considerable amount of boilerplate code for even the simplest message exchange. Additionally, defining a common message format is necessary to ensure smooth communication between players and the server.
This reminded me of a quote:
If you aren't using a framework, you are building one
So by choosing to use this approach, I would have to write code to handle the basic stuff.
I have also have created some diagrams for this but I
Discarding the prototype
I tried searching a different solution because using the former path was not viable, I was reinventing the wheel, and I was sure somebody had already solved the problem of a real-time multiplayer game server, so I searched for a solution.
So I begin searching for a framework that could help me in my particular problem.
My requirements for the framework that I was searching were:
- Have a built-in way to handle room creations
- Support JavaScript (for the client side) by having a native client
- Have a solid documentation
- Easy event emitting and handling
- Be FOSS
Colyseus
The framework I stubble upon is called Colyseus, and I'd like to take a moment to elaborate on what makes Colyseus so good for my case. It is an open-source, actively developed, highly versatile framework designed to facilitate the development of multiplayer games and real-time applications.
It provides a robust and efficient server-side infrastructure for handling the complexities of real-time interactions, making it an excellent choice for game developers, as well as anyone building applications that require synchronized and real-time experiences.
In my personal experience, even with minimal prior exposure to Node.js, I found it surprisingly accessible to grasp the fundamentals of Colyseus. The clear documentation, along with the provided examples, enabled me to quickly understand the core concepts and principles of this framework.
It also provides a debugging interface a βPlaygroundβ that can show all the rooms that have been created, the state of each room, the players that have joined those room, and many more helpful features.
Another alternative to Colyseus is Namaka by Heroic Labs, but it requires a dedicated server (SSH-able) that can run a Docker container but Colyseus on the other hand only requires Node.js.
The architecture
Having a solid understanding from early of data flows, the game flow, and the architecture can speed up decision-making thought the development
Colyseus follows the authoritative server paradigm, ensuring a singular truth for streamlined development and prevention of cheating. In essence, authoritative implies that the server maintains and disseminates the game state to clients. Clients merely need to present the state in a visually appealing interface and emit events, for example when the user respond to a question an event is emitted and the server handles it.
State
In our Quiz, the game state encompasses player scores, the ongoing question, the remaining time, the available 4 responses and each player's response details, such as when and what they answered.
Colyseus provides an elegant method for defining this state, employing a schema that accommodates both straightforward data types like numbers and strings, as well as more intricate structures such as arrays and sets.
I have defined the following schema for the Quiz:
// Import necessary classes from the Colyseus schema library
import { Schema, type, ArraySchema, MapSchema } from "@colyseus/schema";
// Define the Player class representing the state of an individual player in the quiz
export class Player extends Schema {
// Session ID uniquely identifying the player's session
@type("string") sessionId: string;
// Player's username
@type("string") username: string;
// Number of lives the player has, initialized to 3 by default
@type("number") lives: number = 3;
// Player's score, initialized to 0 by default
@type("number") score: number = 0;
// Answer chosen by the player
@type("string") player_answer: string;
// Time when the player provided an answer
@type("number") player_answer_time: number;
// Counter for the number of consecutive correct answers
@type("number") streak_correct: number = 0;
}
// Define the MyRoomState class representing the overall state of the quiz room
export class MyRoomState extends Schema {
// Boolean indicating whether the game has started
@type("boolean") gameHasStarted: boolean = false;
// Boolean indicating whether the game is over
@type("boolean") gameOver: boolean = false;
// Category of the current question
@type("string") questionCategory: string = "";
// Text of the current question
@type("string") question: string = "";
// Array of possible answers to the current question
@type([ "string" ]) answers = new ArraySchema<string>();
// Correct answer to the current question
@type("string") correctAnswer: string = "";
// Map of players, where keys are player session IDs and values are instances of the Player class
@type({ map: Player }) players = new MapSchema<Player>();
// Time remaining for the current question
@type("number") timer: number;
}
A visual representation of the player's and room state.
The game flow
-
Player Joining the Room:
- When a player joins the game room, their
sessionId
andusername
are assigned. - This player object is then added to the
players
map in theMyRoomState
class.
- When a player joins the game room, their
-
Starting the Game:
- Players can trigger the start of the game by sending a "start_game" message.
- Upon starting the game, the room broadcasts a "players_get_ready" message to all clients.
- The game checks if it has not already started (
gameHasStarted
isfalse
), and then locks the room to prevent new players from joining.
-
Game Loop:
- The game loop continues until the game ends (
gameOver
becomestrue
). - During each iteration of the loop, the game follows these steps:
- Generates a random question using the
QuestionsAPI
. - Updates the game state with the new question, answers, and other relevant information.
- Starts a timer for players to answer the question.
- Listens for player answers and records them.
- If all players have answered or the timer runs out, the game proceeds to calculate scores.
- Checks if at least one player is still alive (has lives remaining). If not, sets
gameOver
totrue
.
- Generates a random question using the
- The game loop continues until the game ends (
-
Calculating Scores:
- The game calculates scores based on player answers and time taken.
- If a player answers correctly, their score increases based on the time taken to answer.
- If a player answers incorrectly, they lose a life.
- Additionally, streaks of correct answers can grant extra lives.
-
Ending the Game:
- The game ends if no player is alive or if a specific condition is met.
- Once the game is over, the room unlocks, allowing players to leave or join other rooms.
In summary, the game flow involves players joining the room, starting the game, iterating through the game loop to handle questions, answers, and scoring, and finally ending the game when certain conditions are met. Throughout this process, the game state (MyRoomState
) is continuously updated to reflect the current state of the game.
Deployment
I opted for utilizing the free service provided by render.com to host both the Node.js Server (Colyseus) and the Vue.js web application (frontend).
It's worth noting that the resources offered in the free tier of the Node.js may go into a dormant (spindown) state due to inactivity, resulting in potential delays of 50 seconds or more for requests.
You can access the game here, but please be aware that there might be a slight delay when creating a room. This delay is caused by the potential spin-down of the Node.js instance due to inactivity.
Top comments (3)
Nice write up. Thinking for a long time to play around with Colyseus as well. Will probably use some of your learnings. Thanks!
I enjoyed reading through your process Konstantinos. The pertinent take-away being that prototyping does pay off, especially when it avoids you getting stuck with the wrong solution.
This is super helpful! Will try to follow this advice to build my own game using Colyseus!