In this article, you'll learn the basics of how to create and deploy a real-time, persistent-connection multiplayer game server on Red Hat OpenShift. You will use two popular tools, Node.js and WebSocket, to accomplish this goal.
In 2019, the global gaming market was valued at $151.55 billion, and it is forecast to grow to $256.97 billion by 2025. With a large portion of that value coming from online multiplayer games (which operate using multiplayer game servers), it is worth understanding the technology that drives that industry and how we as developers can leverage technology to create and deploy multiplayer game applications.
Though this article focuses on a server that's primarily used in the gaming industry, the principles, skills, and tools that it covers are directly transferable across different industries and all aspects of application development. Some common applications that use the same underpinning technologies are text-based chat apps (such as Slack), voice communication apps (WhatsApp), and video conferencing applications (Zoom).
Prerequisites
Before you dive into the nitty-gritty, there are some of prerequisites that will help you get the most out of this article.
Skills:
- Terminal emulator or command-line interface (beginner)
- JavaScript (beginner)
Tools:
- Node Package Manager (NPM) installed on your machine
- Access to Red Hat OpenShift
Note: If you plan to use the free OpenShift resources outlined in Step 2, you will need an IBM Cloud account.
Basics of an online multiplayer game
First, it's important to understand the basics of a real-time, persistent-connection application and what a multiplayer game and multiplayer game server really are. Here is a brief summary.
A multiplayer game allows for different players (usually in different locations) to play the same game at the same time. An example would be an online sports game where players can compete in their favorite sport with other players, often communicating through text and voice as they play. As with text, voice, and video conferencing applications, the whole purpose of online games is to create the illusion of proximity even when players are great distances apart.
Online multiplayer games use real-time, persistent-connection technologies to make this possible. These technologies enable devices to communicate with one another continuously by creating a two-way communication bridge that allows for real-time sending and receiving of information/data. This is most commonly used in a client-server model, where a client (such as a web browser) opens a continuous connection with a server (such as a Node.js server).
As mentioned earlier, a great example of this is a modern, text-based chat application. This type of application allows multiple users, via a web browser, to communicate with each other in real time as all of the browsers are persistently connected to a server that can receive and send messages continuously to all users/clients at any given moment.
In the same way, an online multiplayer game uses real-time persistent connection technology to connect multiple players together via a game application (client) that's connected to a game server hosted somewhere in the cloud.
In this article, you will use a communication protocol and technology called WebSocket to create a persistent connection between your game application and your game server. Websocket is a popular and well-documented technology that's commonly used in applications such as these. If you are interested in learning more about Websocket, check out the Websocket API documentation.
Create the multiplayer game server
Now that you have a general idea of how an online multiplayer game works, let's create the multiplayer game server. This server does two things:
- Connects to players playing the game
- Allows players to pass information about themselves to other players who are playing the game (position, actions, etc.)
This allows players connected to your server to feel as if they are playing in the same space as other players, even though they are located in different places.
Your multiplayer game server will be built on a Node.js-based server. In order to build and run your application, you need to have NPM installed on your machine. If you do not have it installed, see the NPM documentation for instructions on how to install it on your machine.
Next, create a new folder on your machine called multiplayer-game-server
, which will hold all the files you need to run your server.
After you've done that, open a terminal window and navigate to that folder via the terminal using the cd
command:
cd <you-directory-path>/multiplayer-game-server
After you're in the folder, initialize your Node.js application using the npm init
command in your terminal:
npm init
This takes you through a series of steps to set up your Node.js application. Press Enter or Return to accept any default values until the process is complete.
Note: Make sure that the entry point
option is set to server.js
:
entry point: (index.js) server.js
After that's complete, you should see a few new files in your multiplayer-game-server
folder.
Now you need to create the file that will actually house the code for your multiplayer game server. Create a file called server.js
in your folder. You can create this file however you like, but make sure that the extension is .js
. To create the file from the command line, use this command:
touch server.js
Now that your server.js
file is created, open the file in your favorite text/code editor. Then, add these two lines of code to the top of your file:
var uuid = require('uuid-random')
const WebSocket = require('ws')
These two lines import into your Node.js application two code packages/frameworks that you will need:
- WebSocket (
ws
) for managing your persistent connections with clients - A random user ID generator (
uuid-random
) that you will use to assign unique IDs to connected clients so you can easily track them on your server
Now that you have imported the packages into your code, you need to actually install them into your application. Navigate back to your terminal and insert this command:
npm install ws uuid-random
As you may have guessed, this command installs the WebSocket and random user ID generator packages into your application folder so that you can now us them in code.
Now let's navigate back to the code editor and add these additional lines of code after your package imports:
const wss = new WebSocket.WebSocketServer({port:8080}, ()=> {
console.log('server started')
})
//Object that stores player data
var playersData = {
"type" : "playersData"
}
The first line of code that begins with const wss=...
actually creates the WebSocket server that clients will connect to at environment port 8080. It is important that you use port 8080 because when you push your server application to OpenShift, applications are exposed to port 8080 by default. In order for your application to work on OpenShift, the app needs to be started on that port to be accessible.
The second line, var playersData =...
, is a JSON object that is used to track players/clients that have connected to the server. Although WebSocket does this by default, it is important for you to have your own mechanism for tracking these users as they sometimes need that information to perform custom actions.
Now that you have inserted code to start your WebSocket server and track connected players, let's add WebSocket functions that you will need for communicating effectively with connected players/clients. After the previous code, add these lines of code:
//=====WEBSOCKET FUNCTIONS======
//Websocket function that manages connection with clients
wss.on('connection', function connection(client){
//Create Unique User ID for player
client.id = uuid();
console.log(`Client ${client.id} Connected!`)
playersData[""+client.id] = {position: {} }
var currentClient = playersData[""+client.id]
//Send default client data back to client for reference
client.send(`{"id": "${client.id}"}`)
//Method retrieves message from client
client.on('message', (data) => {
console.log("Player Message")
})
//Method notifies when client disconnects
client.on('close', () => {
console.log('This Connection Closed!')
})
})
wss.on('listening', () => {
console.log('listening on 8080')
})
//=====UTILITY FUNCTIONS======
function removeItemOnce(arr, value) {
var index = arr.indexOf(value);
if (index > -1) {
arr.splice(index, 1);
}
return arr;
}
Let's break down what all this code does, starting with the following command:
wss.on('connection', function connection(client){...
This is called the "OnConnection" WebSocket listener method. This essentially listens for a client to connect and then manages the persistent connection with that client from then on. Note that most other client-server connection methods/functions will be nested inside this OnConnection method. Since this function manages the connection between the server and client at all times, all other functions will leverage the persistent connection this method manages.
Within the OnConnection method, you have these lines of code:
//Create unique user ID for player
client.id = uuid();
console.log(`Client ${client.id} Connected!`)
playersData[""+client.id] = {position: {} }
var currentClient = playersData[""+client.id]
//Send default client data back to client for reference
client.send(`{"id": "${client.id}"}`)
Essentially, this code sets up the initial connection with your player/client and gives the player a unique identity. First, you need to create and the assign a unique ID to your player (which you set in your playersData
JSON). After the ID is set, you need to send back to the player the ID that the server has assigned to them for future reference. This chunk of code is useful because it gives you the opportunity to set up unique IDs for your players so that in the future you can have custom control over how you manage and track individual players on the server.
After you've created a unique ID for your client/player, it's time to set the ability to receive data/information from the player:
client.on('message', (data) => {
console.log("Player Message")
})
The OnMessage listener method allows the server to listen for messages received from any connected client/player. It "listens" for messages and after it "hears" a message, it retrieves it and allows you to parse that message and do what you like with it. For now, let's keep this method somewhat empty, but you will be returning to it later to add some more functionality.
The next piece of code is used for client/player disconnections:
client.on('close', () => {
console.log('This Connection Closed!')
})
The OnClose method shown above manages what happens when a client closes its connection with the server. The client triggers this method which sends a close
message. This allows you to do any cleanup that's needed when a client disconnects. In this case, you use this functionality to remove a client/player from your playersData
JSON object, which I will cover a little bit later.
Finally, outside of the OnConnection method you have two pretty straightforward functions.
wss.on('listening', () => {...
This function sets up the ability for your server to listen on your specified port. Essentially, it allows the server to work as needed, plus it also has a simple debug statement that you can use to ensure that your server is running/deployed correctly.
function removeItemOnce(arr, value) {...
This is a simple utility function that you will use later on for quickly removing an item from an array.
The code you have just added provides a basic framework for your multiplayer game server. The next steps will be to add the specific functionality needed to send and receive messages to and from the player.
Data transfer between client and server
Now that you have the basic framework for your game server, it's time to add the appropriate code to allow the client/player to communicate properly with your server. In the OnMessage method, add this code:
client.on('message', (data) => {
var dataJSON = JSON.parse(data)
var dataKeys = Object.keys(dataJSON)
dataKeys.forEach(key => {
playersData[dataJSON.id].position[key] = dataJSON[key]
});
console.log(playersData[dataJSON.id].position)
var tempPlayersData = Object.assign({}, {}, playersData)
var keys = Object.keys(tempPlayersData)
//Remove "type" from keys array
keys = removeItemOnce(keys, "type")
tempPlayersData["playerIDs"] = keys
client.send(JSON.stringify(tempPlayersData))
})
Let's work through this piece by piece:
var dataJSON = JSON.parse(data)
First, you need to parse the data received from your client/player from a JSON string into a JSON object as it will be easier to access as an object. This data received contains any information you want to convey to the server (and other players) from the player. This could include position data, actions performed, or chat messages sent, among other things.
var dataKeys = Object.keys(dataJSON)
dataKeys.forEach(key => {
playersData[dataJSON.id].position[key] = dataJSON[key]
});
After this is parsed, you need to retrieve all of the keys that are present in the data. You do this because in your next line of code, you quickly add this data to your playersDataJSON
object using a loop. You are also using the player's ID to associate it to that specific player. This approach allows you to dynamically update data sent from the client. Otherwise, you would need to do it manually — meaning you would need to know every possible key that's present in the data beforehand and then manually assign it to the playersData
object.
var tempPlayersData = Object.assign({}, {}, playersData)
var keys = Object.keys(tempPlayersData)
//Remove "type" from keys array
keys = removeItemOnce(keys, "type")
tempPlayersData["playerIDs"] = keys
client.send(JSON.stringify(tempPlayersData))
These lines of code are primarily for sending all updated player data to the player who just sent their updated player information to the server. The reason you do this is multi-faceted. First, you need to create a dialog between the server and the client/player. This dialog allows the client to always get all of the updated information about all of the other players whenever they send information about themselves. It also ensures that the client/player receives information quickly.
Note: I will not cover this next detail at length in this article, but it also allows the server to verify player data before sending data back to the player. Essentially, before sending information back to the player who just sent information to the server, it is possible for the server to have a verification step to validate if the data sent by the player is valid and correct it if needed (and correct any information that was incorrectly created on the client side). This is for more complex servers, but it's worth noting as this mechanism is used in some circumstances.
The second reason why you send your data back in this manner is that you want to send some additional information to your client (from the server) but you don't want it to affect the data that's stored on the server.
In the next line, you create a copy of your playersData
JSON object so you can modify the object without directly affecting the object on the server:
var tempPlayersData = Object.assign({}, {}, playersData)
After you've made the copy, you need to gather all the keys from your JSON object (removing the type
key) to get a list of all players currently connected to the server. This can save your client some effort so they can easily assign player data to players in the game application. Although this may seem a bit confusing, it is essentially a quick way to allow your client to retrieve information about other players and render it within the game application.
After you have your list of players and you add the required data to your temporary player JSON object, you then send this data to the connected client/player. The client/player can then retrieve the data and use it as needed.
Remove a disconnected player
Finally, let's add code to the server that removes clients/players cleanly from the server after they send a close
message to the server:
client.on('close', () => {
console.log('This Connection Closed!')
console.log("Removing Client: " + client.id)
//Iterate over all clients and inform them that a client with a specified ID has disconnected
wss.clients.forEach(function each(cl) {
if (cl.readyState === WebSocket.OPEN) {
console.log(`Client with id ${client.id} just left`)
//Send to client which other client (via/ id) has disconnected
cl.send(`Closed:${client.id}`);
}
});
//Remove disconnected player from player data object
delete playersData[""+client.id]
console.log(playersData)
})
When the close
message is sent from a connected client who is about to disconnect from the server, this set of code does two things:
It sends a message to all connected clients/players that the player with the specified ID has exited the server (the game). This allows all the other client/players to appropriately deal with that disconnection (for example, removing that player from their game application).
It removes the player with the specified ID from the server's
playersData
JSON object. This allows the server to no longer track information about that client/player and remove any data associated with that player from the game server.
This last set of code is important because it ensures that the server does not get bloated with data that is no longer needed. It also ensures that other clients/players can remove players from their game who are no longer playing.
Here is the code for your completed multiplayer game server:
var uuid = require('uuid-random')
const WebSocket = require('ws')
const wss = new WebSocket.WebSocketServer({port:8080}, ()=> {
console.log('server started')
})
//Object that stores player data
var playersData = {
"type" : "playersData"
}
//=====WEBSOCKET FUNCTIONS======
//Websocket function that manages connection with clients
wss.on('connection', function connection(client){
//Create Unique User ID for player
client.id = uuid();
console.log(`Client ${client.id} Connected!`)
playersData[""+client.id] = {position: {} }
var currentClient = playersData[""+client.id]
//Send default client data back to client for reference
client.send(`{"id": "${client.id}"}`)
//Method retrieves message from client
client.on('message', (data) => {
var dataJSON = JSON.parse(data)
var dataKeys = Object.keys(dataJSON)
dataKeys.forEach(key => {
playersData[dataJSON.id].position[key] = dataJSON[key]
});
console.log(playersData[dataJSON.id].position)
var tempPlayersData = Object.assign({}, {}, playersData)
var keys = Object.keys(tempPlayersData)
//Remove "type" from keys array
keys = removeItemOnce(keys, "type")
tempPlayersData["playerIDs"] = keys
client.send(JSON.stringify(tempPlayersData))
})
//Method notifies when client disconnects
client.on('close', () => {
console.log('This Connection Closed!')
console.log("Removing Client: " + client.id)
//Iterate over all clients and inform them that a client with the specified ID has disconnected
wss.clients.forEach(function each(cl) {
if (cl.readyState === WebSocket.OPEN) {
console.log(`Client with id ${client.id} just left`)
//Send to client which other client (via/ id) has disconnected
cl.send(`Closed:${client.id}`);
}
});
//Remove disconnected player from player data object
delete playersData[""+client.id]
console.log(playersData)
})
})
wss.on('listening', () => {
console.log('listening on 8080')
})
//=====UTILITY FUNCTIONS======
function removeItemOnce(arr, value) {
var index = arr.indexOf(value);
if (index > -1) {
arr.splice(index, 1);
}
return arr;
}
Deploy your multiplayer game server to OpenShift
Now that you successfully created your multiplayer game server, it's time to deploy the server to an OpenShift cluster. The great thing about this is that OpenShift does most of the deploying work for you. In addition, you get access to the powerful monitoring, automation, and logging tools that OpenShift offers out of the box. Compared to coding and creating the server, this is the easy part.
So to make this a bit easier to follow, I will break down this deployment process into steps.
Step 1. Publish code to a code repository
In this step, you need to push your code to a code repository such as GitHub, GitLab, Bitbucket, or any other code repo tool that uses a Git-based source code. I recommend setting your repository to be public as it makes the next steps a bit easier. You can set it to be private, but that requires a few additional steps (which I do not cover here) in order to connect it to OpenShift. I do not go through this step-by-step process, but there are plenty of resources available online that show you how to publish your code to an online code repository.
After your code is accessible via the internet, you need to connect your repo to the OpenShift project to quickly build your server using the source-to-image method. I'll describe that in more detail in the coming steps, but before that you need to provision your OpenShift environment for use.
Step 2. Provision a free OpenShift environment
Note: If you already have access to an OpenShift environment, you can skip to Step 3.
So one tricky thing about getting started with OpenShift is that it can sometimes be tough to get hands-on experience with the tools since, in general, OpenShift has a cost associated with deploying it on the web.
Luckily, IBM has some resources that allow anyone to get hands-on time with OpenShift for free!
One of those resources is a preconfigured Red Hat OpenShift on IBM Cloud environment provided by IBM Open Labs.
The only thing you need to access the resources is a free IBM Cloud account. If you do not have one, be sure to sign up for your IBM Cloud account. After you have your account, you can use the IBM Open Labs to get a provisioned OpenShift environment for 4 hours at no charge.
If you want more details on how to set up your OpenShift environment, visit Access OpenShift Cluster at Open Labs for a walkthrough of how to access an OpenShift cluster via IBM Open Labs.
Reminder: After you launch the lab, your four-hour time limit for using the OpenShift instance begins. You can always relaunch the lab later, but be aware that this instance is deprovisioned after that time.
Step 3. Create a project in OpenShift
Before you can deploy your Node application, you need to create a project that your Node.js game server application will be associated with. This is a very simple process that should only take a minute or two.
First, you must change your OpenShift dashboard view to be the Developer perspective. (The default view for the OpenShift web console is the Administrator perspective.) To do this, go to the navigation panel, open the Perspective Switcher drop-down menu (where Administrator is currently highlighted), and select Developer, as demonstrated in the following screen capture image:
When you switch to the Developer perspective, you might be presented with a Welcome to Dev Perspective pop-up window that looks similar to the following image. You can select Skip tour for now, but feel free to select Get Started to get an overview of the Developer perspective.
Now let's create the project. From the navigation panel, click Topology. Then open the Project: all projects drop-down menu and select the Create Project option, as illustrated in the following screen capture.
After you select that option, you should be presented with a Create Project pop-up window. Enter any name you like in the Name field. I used multiplayer-game-server-app
(note that the name must be in lowercase letters). All the other fields are optional.
After you enter this information, select the Create button.
Now that the project is created, you should be presented with the Topology page where a No Resources Found
message is displayed. In the next step, you will deploy your Node app — your first resource.
Step 4. Deploy your game server to OpenShift
It's time to deploy your multiplayer game server. Just a few more steps and your app will be live!
Now that you have your OpenShift instance and your project created, you can now use OpenShift's source-to-image (S2I) method to quickly and easily deploy your application. This functionality takes the code from your Git repo, builds a container image, and deploys it into your OpenShift environment. It does most of the hard work for you.
To build and deploy your application, you will use the From Git method to build and deploy out the application. With this method, you initiate the S2I process, watch your application get deployed, and view the results. Essentially, OpenShift automatically identifies what type of code base is being used and then uses the appropriate containerization process to create a container image. You only need to do a few small things.
On the Topology page, select the From Git option.
On the Import from Git page, enter the URL for your Git repo in the Git Repo URL text box.
After you insert your Git repo link, it should automatically identify that you are using a Node.js builder image for your application.
The nice thing about S2I is that it can save you a lot of time by automatically identifying the language you are using to build your application.
As you move down the page, you will see the Builder Image version drop-down menu. In this case, the default version selected should be fine.
All that's left is to give your app a unique application name and component name. I used multiplayer-game-server-node-app
and multiplayer-game-server-node
, respectively.
As you move down the page, you should see the Resources and Advanced Options sections. Under Resources, ensure that the Deployment option is selected. Under Advanced Options, ensure that the Create a route to the Application checkbox is selected. This ensures that a public URL is created for your newly created application.
After you confirm all of those options, click Create. This will take you back to Topology page where you should see that your application now exists.
During the next few minutes, you should see your application go through the process of being built. As demonstrated in the following image, a small icon near your resource/application should change. It may take a few minutes, but when the green checkmark appears, it means that your application successfully deployed.
If you select your application in the Topology view, a details panel opens that shows you more build, services, routes, and monitoring information.
Now that your application is up and running, you can either select New window (there is an icon located just above the Node logo) on your resource in the Topology view to open your server, or navigate to the bottom of your details panel under the Resources tab and select your URL under the Routes section.
Either option will open your application URL and only the words Upgrade Required
should appear on the page, as demonstrated in the following screen capture.
Sometimes, you may see an error page with the words Application is not available
even after the application indicates that it was built and successful deployed.
There are a few reasons why this might happen. The two main ones are:
- The application is still in the process of starting, Although it finished building, the app and needs a bit more time to be ready (about 1-2 minutes). Feel free to check the logs for your application deployment to make sure everything looks fine by selecting the View logs button in the application details panel. It is located in the Pods section under the Resources tab.
- The hosting port that you selected in your server application does not match what OpenShift is expecting. By default, OpenShift exposes the app at the 8080 host port. If you identify a different port in your application code, that can cause deployment issues. To fix this, just ensure that the port selected to be hosted in your code is 8080. After you make that change, push the new code to your same repo and select Start build from the application details panel under the Resources tab. This automatically rebuilds the application from your repo by using the updated code.
Congratulations! You successfully deployed a Node.js Game server to OpenShift!
Game/Client application connection
You may have noticed that we did not cover how to connect a client to the server you just deployed into OpenShift. This article does not cover that part of the process, but I encourage you to investigate how to implement WebSocket into a front-end or game application and connect it to the game server. You can use any game engine that has the ability to use WebSocket (such as Unity) and experiment with how to send and receive data.
If you are interested in learning how this server works when connected to a game application, tune in to my webcast, Deploy a game server on Red Hat OpenShift, that aired on 1 December 2021 at 11:00 AM ET. It demonstrated how the interaction between the game application (client) and the game server works. Jump to the 00:50:28 timestamp in video to see the connection in action (Video timestamped below)
Summary
Although real-time, persistent-connection applications development is very common in the technology landscape, many developers may see it as an out-of-reach skill. In this article, I showed you how to develop and create an application that uses real-time persistent connections, and demonstrated how easy it is deploy an application to modern cloud technology such as OpenShift. With the knowledge that you gained from creating an online multiplayer game server using WebSocket and Node.js, you are now more equipped to contribute and compete in the ever-evolving technology and development landscape.
So what's next? I encourage you to investigate other use cases and applications that use real-time, persistent-connection technology. For example, how would you create a real-time chat application by using ReactJS as your front end and the NodeJS server that you created here? How would you pass real-time audio streaming data to your NodeJS server? How would you host those applications in OpenShift so they could be accessed by anyone who wants to use them? As you work to answer questions such as these, you can continue to unlock more in-depth knowledge on real-time, persistent-connection applications, and take your knowledge to the next level. And don't forget to investigate more knowledge here on IBM Developer, where we cover cloud technologies, containers, and more.
Thank you for reading and I hope this was helpful!
Onward and Upward My Friends,
Bradston Henry
==== FOLLOW ME ON SOCIAL MEDIA ====
Twitter: Bradston Dev
Dev.to: @bradstondev
Youtube: Bradston YT
LinkedIn : Bradston Henry