Architecture of a Node.js multiplayer game
Technologies such as HTML5, Node.js and socket.io make it possible to create not only interactive and collaborative applications, but also multiplayer games that work directly in the browser, without any third party plug-ins.
This article is based on Frienzzle, a multiplayer jigsaw puzzle game that I’m currently writing, but the general architecture that I’m describing here can be applied to various types of multiplayer games, including simple RPG and RTS.
I’m going to focus on the actual game engine here. Technically it’s a page which contains a large canvas element and loads the client-side script. To create a working game you will need a bit more than that. Recently I wrote an article which shows how you can combine a traditional PHP web application with a Node.js engine. It might be useful if you are just starting to use Node.js like me.
In case of Frienzzle, the client-side part of the game consists of a single script bundled using Browserify, together with the socket.io client library, and a single picture used for the puzzle. More complex games may require more scripts, images and other assets, so you may need to use something like Webpack, but I’m not going to cover it here.
High level architecture
Let’s start with a diagram which shows the architecture of both the server-side and the client-side part of the game:
The main components of the system, from top to bottom, are:
- ServerController. Handles new connections from clients, performs authentication, creates and destroys game and player controllers when necessary. It’s also responsible for persisting data to a database, logging, and other maintenance tasks.
- GameController. Depending on the type of the game, there is one object per game world, game room or other virtual place where multiple users can play together. It is responsible to changing the state of the game in response to player actions and for notifying players about these changes.
- Game. Represents the current state of the game on both the client and the server. I will write more about it in a while.
- PlayerController. There is one object per each client connected to the server. It handles incoming messages from the client and performs actions in the game on behalf of the player. It also periodically sends messages containing updates of the state of the game to the client.
- ClientController. There is one object per client. It handles incoming messages from the server and updates the state of the game. It also handles user input and sends messages about user actions to the server.
- GameView. Renders the game on the canvas element and passes input events from the browser to the client controller.
As you can see, the architecture of the client-side part is similar to MVC. The main difference is that the controller updates the model (i.e. the state of the game) based on input from the server, not user input. In practice however, we want user actions to be visible immediately, without waiting for the round-trip between the client and the server.
The solution is to separate the model of the game into separate logical parts:
- Immutable data. This is the data that never changes during the game. In an RPG game this can be the terrain, in case of puzzle game it’s the shapes of individual pieces. This data only needs to be sent to the client once at the start of the game and the client can assume that it’s always accurate.
- Persistent state. This is the data that can change during the game, for example the position of characters or position of puzzle pieces. The client has a snapshot of the state of the game stored on the server which may no longer be accurate. For this reason, the client should never modify this state directly in response to user action, only in response to messages from the server.
- Temporary state. It’s the difference between the persistent state and the state seen by the user. It’s only needed on the client side. For example, when the players’s character moves, the temporary location is changed immediately, so the movement animation is smooth, but the persistent location is only updated when the server confirms this change. The server can reject the change, for example when another object blocks the way in the meantime.
The architecture of the server is also a bit similar to MVC. There is one model for each game world. The model is updated by the game controller in response to player actions and other game events. Although there is no traditional view on the server, the player controllers act in a similar way to views — they receive updates when the state of the game is changed, but instead of updating the screen, they relay these updates to the clients. They also relay user input from the clients to the game controller.
Example data flow
All this may sound very complex, so let’s analyze a simple scenario to see what’s really going on under the hood. Imagine a simple game when you can move your character using keyboard. Let’s see what exactly happens when the user presses the right arrow key.
These are all the steps in chronological order:
- The game view reacts on the key down event sent by the browser and sends a “move right” event to the client controller.
- The client controller checks if the character can move based on the current snapshot of the game state. For example, if there is a wall which blocks the way, it could discard the event. If the action is possible, the client controller updates the temporary position of the character and redraws the game view. It also sends a “move right” event to the server using a web socket.
- The player controller on the server receives a “move right” event through the web socket. It passes the event to the game controller along with the information about the player whose character is to be moved.
- The game controller checks if the given player’s character can move to the right. Based on that information, it might accept or reject the action. If the action is possible, the game controller updates the persistent state of the game and sends the “state changed” event to all player controllers associated with the game.
- The player controllers serialize the data that has changed and send it to the clients using their corresponding web sockets.
- The client controllers that are currently connected to the game receive the “state changed” event with the new data. They update the persistent state of the game and redraw the view if necessary.
Managing the complexity
Like any real-time distributed system, a multiplayer game is not easy to implement. You have to take into account the multiplayer nature of your game right at the start when designing the architecture of the game. There are many challenges that you will face, for example it’s hard to catch all subtle bugs that can arise from various race conditions. So start with something very simple and make sure it works correctly in all possible scenarios, including network lags and temporary loss of connectivity.
Fortunately Node.js offers some tools that make our life easier. For example, using EventEmitter makes it easier to decouple components of the system. The game view and the game controller are both event emitters; their events are consumed by the client controller and the player controllers, respectively. The client and player controllers also communicate using events passed though their web sockets.
All code shared between the client and the server goes into
lib/shared/, code specific to the client or the server goes into
client.js is the entry point for the client-side bundle and
server.js is the entry point for the Node.js service which runs on the server.
The first version of Frienzzle will be officially released next week! Go to frienzzle.com and subscribe so you don’t miss the release.