Making a game server can be a hard task. Multiplayer games require the server to respond as quickly as possible and sometimes even predict what’s going to happen to avoid issues caused by players having different latencies. In this article, I’ll explain how I made my game (RPGMod) have a server script capable of handling up to 1000 simultaneous players per instance without issues, on Node.js.
Game Server VS Web Server
First, what is the difference between a game server and other types of servers (such as the one for this website, called a web server)? Well, pretty much everything. A good response time for a website is about 200ms, for a game it’s about 20ms. Websites can respond to requests asynchronously to improve performance, while game servers need to respond to everything in a specific, synchronous order to not cause problems. A web server has a lot more time to send and process information than a game server, and can even take advantage of Content Distribution Networks (CDNs) to improve the user experience, while game servers have to do everything on their own and are a lot more constrained in regards to time.
Another basic difference is: a web server uses mostly HTTP, whereas a Node.js game server will likely use WebSockets (much faster for continuous communication). Since dealing with WebSockets isn’t that straightforward server-wise, I’d recommend you use “Socket.io” for it.
The basics
Well, first thing to go over for optimization are the basics: Cache everything, Multithread when you can, Optimize Loops. Caching means if you did a calculation that will be done again in the future, cache and re-use it. If you access an object’s value that you’ll need again in the future (which is costly on Node.js) put it in a variable. If you’re going to read a file, put it in a variable and next time you need it, read the variable instead (the RAM will always be faster than the disk/SSD).
Loops can be done in a variety of ways in JavaScript: you can use forEach, for of, while, do while, for, etc. They all work in very similar ways and can achieve basically the same results, but if you want the BEST possible performance, use a for loop. More specifically, like this:
for(var i=value;i--;){
//do work here
}
This is the most optimal way you can write a loop in JavaScript. This loop eliminates the constant check for i = 0 by decreasing its value instead of increasing (for loops stop automatically when the counter variable reaches 0) and you cache the value you want to iterate (say, an array.length) which reduces the amount of checkups for an object’s value. While, do while and for of have similar performances, but in my tests for loops are still slightly faster overall (and for Node.js, in the case of a game server, you’ll need to squeeze as much performance as possible). ForEach loops are an absolute no-go, as they call a function (anonymous or not) for each and every value of the array (and function calls have an inherent performance cost, for as small as it may be). Another thing about loops is, you can skip an iteration and even break out of them using “continue” and “break“, and that can also save processing time. Example:
for(var i=massive_array.length;i--;){
if(massive_array[i]!='something I want) continue //skips to the next value
else{
//do something
break //ends loop, instead of iterating through the remaining values
}
}
For multithreading, it’s a little more complicated. Node.js (and JavaScript on the browser) has threads in the form of workers (web workers for JavaScript web and worker threads for Node.js) and both of those possess a small amount of overhead when communicating with the main thread (which will hold the main logic of the server). This means you’ll need to weigh whether or not giving separate threads data to process instead of the main thread is worth the cost. For small messages, usually the overhead will mean about 0.25ms per message sent, and another 0.25ms per received, but bigger amounts of data will mean bigger overhead. On my server script, I’ve only two threads: main thread having most things (server logic, connections, etc) and secondary thread having the AI logic (A* algorithm) + initial part of encryption (all communication starts encrypted using RSA then upgrades to AES-128 bits).
Advanced bits
Now a more interesting part comes into play, the advanced optimization. Node.js uses v8 from Google as their JavaScript engine, and JavaScript engines tend to be a bit of a black box, but there’s ways to make them work for you. First tip is, use typed arrays whenever applicable. Typed arrays are a more primitive kind of array, they’re used to create a specific view of a data buffer. Each entry is a raw binary value in one of a number of supported formats, from 8-bit integers to 64-bit floating-point numbers. Sorting through them is quite faster than normal arrays.
Another tip: avoid functions. As much as it sounds counter-intuitive, and as useful as they are to not repeat code, Function calls in JavaScript come with a small overhead. Now, normally that isn’t a big deal, but consider the following scenario: Your server has 1000 players, each of them receiving and sending 10 messages per second (10,000 messages in 1 second). If you always call a certain function for all of those messages, if said function takes 0.002ms to be called, that means that 20ms out of every second are spent just running it. It might not seem like much, but every ms counts when you’ve a budget of 20-30ms to send messages to the client without it being considered “laggy”. And on the topic of functions, avoid anonymous functions at all costs. Anonymous functions are functions without name, usually used for callbacks. Those functions never get optimized by the v8, so declare your functions beforehand then use them as callback.
Going back to encryption that was mentioned before, encrypting every single message before sending to the player might be expensive (on my machine, encryption takes 0.02ms which when applied to the scenario above may mean 200ms out of every second spent just encrypting/decrypting) so the less you do it, the better. That’s why I recommend two things: a Ticks system and Grouping messages before encrypting. Ticks, in the case of a game server, means that messages are only sent to the client once every X milliseconds. This is a common design implemented in many shooters (Battlefield, CS:GO, etc), that help keep all players a little more synchronized. On top of that, make sure that all messages that each specific player would receive in that tick are grouped (maybe add a “&&” between each to separate them) before encrypting and sending, thus reducing CPU and bandwidth usage.
Other minor things
To wrap up, here’s a list of minor things you can do that also help to improve performance and to monitor it:
- Keep node.js up to date(every now and then, things get optimized in new versions)
- Run nothing else in the background
- Use node.js’ built-in profiler