How I Made a Multiplayer Snake Game 🐍

Madusha Pallawela
Webtips
Published in
19 min readFeb 14, 2021
How I Made a Multiplayer Snake Game 🐍

Motivations

With an interest in gaming, animation, architecting, and design, I wanted a large personal project that I could really sink my teeth into. I felt making a snake game in React would be a good start and would give rise to this opportunity.

As learning was my main objective, I actively avoided tutorials and the use of libraries. This is so I could be in a position to solve new problems and have a deep understanding of what I’m building.

Note: This is not a how-to guide and is a paper on how I built this project.

Breaking Down the Problem 🔨

Like many problems in Computer Science, or in general, the approach was to break down the problem into smaller subproblems. I started to think about the old school Nokia snake game and what components were involved to make it. This is what I considered:

  • Snake - rendered with squares which represented the head and body
  • Game Board - the area the snake can move
  • UI - Components that represent the score and menu items

The first and most simple step I could take was to render a box. This was essentially just a div, with a green background, which represented the head of the snake. By adding a key event listener, the application would listen for keyboard input and manipulate properties of the box - in particular the position.

With a simple prototype built, I set out to make the real game.

Transforming a Box into a Snake 🗳 -> 🐍

Having a moving box is great, but it’s not exactly a snake. The snake needs to be modeled so that when the snake head moves in a direction, its body follows. Or in other words, where ever the head goes the body goes.

Fortunately, representing this as simple as we can use an array to hold the positions of the snake cells.

  1. Keyboard arrow key input is detected to work out the direction of the snake (default to right at the start of the game).
  2. Shift everything in the array left by 1 place. We do this so that each body cell takes the place of its adjacent cell and ultimately the head.
  3. Update the head position of the `snakeCells’ based on direction. For example, the arrow key right would increase the x position by 2px whilst the left would decrease the x position by 2px.

Note: After doing a copious amount of leetcode, I realised I could have just used shift() and push() to remove and add a new element.

This brings us one step closer towards making a snake game but something is missing. In a snake game, the snake accelerates on its own where the Player’s input only influences its direction.

Gameloop

The game loop’s initial job would be to move the head, and subsequently the body, based on the snakes' direction. The initial implementation for this was to use setInterval() on page load. This would constantly evaluate the position of the snake and update the head in relation to the direction.

For example, if the snake direction was set to go “right”, then the snake head would constantly move “right” — without the need of the player holding/pressing “right”.

This is where I learned a lot about react hooks and the nuances of the useEffect() hook in React.

Simply put, the useEffect() hook is used to tell React that your component needs to do something after React has updated the DOM.

In this case, I used it to set off the loop which would start the game. However, I noticed the game did not work as expected as the direction was not being updated within the loop. That’s because useEffect was not supplied with the state dependencies (see below). This resulted in the state not being updated within the loop and only using its initialised value.

Further on down the development road, this loop would eventually be refactored into something else entirely (see Canvas Refactor)

Improving How the Head Looked

At this point in time, the head looked no different to the body. I wanted to change that.

I used my primitive Photoshop skills to draw a head and replace it with an image. With some tweaking to position, scale, and considerations for rotation, the snake head was now aligned correctly within the game.

An improvement to this would be to render the snake head via the Canvas API instead of using an image. The main benefit would be that adding a new snake colour would be far less manual and handcrafted.

Food and Snake Growth 🍎

A game always needs an objective and a challenge. For snake, it is eating food and growing retrospectively. To build the food mechanic it involved the following:

  1. Randomly positioning the food in the game space
  2. Once the snake is close to the food (distance from snake head and food within a range),
  3. Update score and move food to random location
  4. Increase length of tail by pushing a new cell into the snake data structure

This also brought the opportunity to add a touch of detail to the snake head. When the snake is close to the food, I can switch the head image with one with its mouth open.

Even though my drawing skills here are juvenile, when in motion, this simple swap gives the illusion that the snake is eating the food.

Cache Issues

Interestingly, at this point, whilst adding the snake head images to the project, I noticed within the network requests, it was not serving the image from cache, i.e loading the image once and reusing it. I was a bit puzzled as to why it was constantly fetching the head image within the loop. After some Googling:

“To reduce the number of requests to the server, importing images that are less than 10,000 bytes returns a data URI instead of a path. This applies to the following file extensions: bmp, gif, jpg, jpeg, and png”

I worked out that the issue was that I exported the images as .png with out compression. This lead to it not being cached - as the image was too large. After tweaking the export settings, I reduced the file size and this resolved the caching issue.

Autopilot (pseudo AI) 🤖

I always liked the idea of automation so I thought, why not automate the snake? If we know the position of the food, and where the snake’s head is, then all we’d have to do is move the head one step closer to the food every tick of the game loop.

let distanceX = food.x - snakeHead.x
let
distanceY = food.y - snakeHead.y

Depending on the values of distanceX and distanceY we can modify the snake’s head position one step at a time until it resolves to the coordinates (0,0) — i.e where the food is.

A nuance of this is that to adhere to the rules of snake, the snake head x and y coordinates should only be updated individually and not at the same time. Updating x and y, at the same time, will make the snake move diagonal. I resolved this by having a priority of resolving the x distance down to zero and then y.

This solution works. As long as you totally ignore the fact the snake can go through itself. This is where there is loads of room for improvement. I’ve looked up strategies to do this in a more exhaustive fashion which adheres to the rules of the game and will implement them at a later time.

Once I added this feature I had an epiphany. I could use this autopilot feature to add a “vs CPU” mode. This required refactoring the code to handle multiple snakes on the game board. The game mode essentially worked by adding a second snake into the system which had autopilot switched on.

And….that was it. Well, sort of. I also updated to UI to reflect the score of the CPU snake but the bulk of the CPU logic was already made.

Canvas 🖌

As I played around with autopilot and went to get a coffee I, noticed when I came back, that the entire game was stuttering tremendously. As the snake grew, the slower the entire game became. After doing research, I realised the problem was pretty obvious, though admittedly I was quite puzzled at first.

React is for building UIs and web applications — not re-rendering hundreds of components at the same time at such speed. As the snake grew in size it started to take its toll and slow down the entire application. When 50 plus components were being re-render, multiples times a second I knew I had to change my approach.

The solution to this? Replace the core rendering mechanism with the Canvas API.

The Canvas API is designed for this exact purpose and it can be used for animation, game graphics, data visualisation, photo manipulation, and real-time video processing. The idea is that instead of using HTML elements to represent game components, I can draw graphics within a defined space (the Canvas).

To give the illusion of animation or movement, I repeatedly drew each frame and then cleared it:

Draw on the canvas -> Clear canvas -> Draw on the canvas

Fortunately, the game logic I had already built was still good to use, I just needed to replace the rendering of the game elements (snake, food etc) via the Canvas API. I still used React for the UI component around the game board — such as the menu, scores, and settings.

AutoPilot Grid Line

Canvas made it easy to add visual features such as the “Auto-Pilot grid lines”.

This effect was achieved but drawing two lines, one for the y axis towards the food and one for the x-axis (or vice versa). I used the Canvas API lineTo(x,y) method which draws a line between two points.

The gradient effect, in all honestly, was a bit of a fluke and happened when I was playing around with the colours. It was achieved by having a gradient colour space, from red to green, within the line.

This gave the illusion of distance being represented through colour. Red being the snake was far from the food and green being close.

RequestAnimationFrame

Alongside introducing Canvas this was also an opportunity to improving the game loop mechanism. As previously mentioned, up until this point, I was using setInterval() however I discovered I could use requestAnimationFrame instead.

RequestAnimationFrame does the same thing as setInerval() but is more optimised for browsers. This means that animation code is called when the browser is actually ready to make changes to the screen. This results in a smoother, more efficient animation at 60 fps.

The caveat with this however is that I initially designed the game loop to run at 100ms. This meant when I used RequestAnimationFrame, which runs at 60fps, the entire game ran at bullet speed. I realised I had to drastically reduce the space in which the snake moved per iteration. The only way to succinctly describe the result of making these changes is that the game ran a lot smoother.

Here is the initial prototype for the game. No…the video isn’t lagging, this is actually how it looked.

The pertains to the illusion of animation. If you move something a little bit per iteration and do it quickly, it will result in “smoothness” If you don’t, then it results in choppiness.

Compare the previous video with the following:

Acronym

This feature is a lot more niche but the idea came out of “gamifying” learning. The TV iPlayer space (where I currently work) has a lot of acronyms — to the point there are acronyms within acronyms. By modifying the UI and having the acronyms and the definitions around the food, this was an attempt, perhaps feeble, of subconsciously teaching these acronyms by simply playing. By default, it is switched off.

Multiplayer

So, this where I started to take this game to the next level (pun intended). I decided that I wanted to push this project further and build a multiplayer component into the game.

To build this portion of the game I had to really think about the problem I was trying to solve. I needed player input from different browsers to be directed into a central point and distributed back out to the clients with state change. Or put simply, the state of the game needs to be shared with everyone playing the game.

This is where Socket.IO comes in. Socket.IO is a library that enables real-time, bidirectional, and event-based communication between the browser and the server.

Source: https://socket.io/docs/v3/index.html

After setting up a Socket.io server and having the application connect as a client, I started to build the multiplayer component to the game.

My initial approach was to store the entire state of the game in the server where clients would poll for changes on each loop. Clients would also be able to make changes to the state of the game stored on the server — i.e update a snake’s position or direction.

This….worked, but was not scalable at all. The reason being is that each client, with RequestAnimationFrame running, would be absolutely BATTERING the server with 60 requests per second. With 3 players connected, the game would slow down and lag spikes would occur. Vertically scaling the server (increasing CPU power) would help alleviate this a little but this was more of an issue of overall implementation.

Multiplayer Redesign

After a lot of thinking and researching, I needed to redesign my approach. So much so, I had to flip my current implementation upside down. Instead of the server holding the state of the game, client events need to be communicated to other clients via the server.

For example, if a player (Player 1) presses down:

  1. Player 1 sends a “direction” event to the server
  2. The server will receive this message and then broadcast this message to all the other clients
  3. Clients will receive the broadcast message, evaluate it and update the state in their game i.e make player 1 go down in their game

This means the game now reacts to player events and simulates what has happened across the clients. There are some caveats with this which became more apparent as I developed multiplayer further.

Under an event-based system where the state is not stored in a central place, how does a new player joining get the state of the game?

To solve this, I devised a handshaking protocol to sync the state of the game:

Ensuring that a new player has the latest state of the game

Synchronising state 🤝

The interesting thing about running a server locally is latency is not a noticeable problem. However, once an application is deployed, for example, hosted on an EC2, we start to see issues.

Players can start to drift away from each other due to latency and start to move out of sync with each other. This is because depending on where the client is in relation to the server, events arrive at different times. This is compounded by the fact that when the server broadcasts a message, it doesn’t arrive at the same time for all the clients due to latency.

A way to solve this would be to have a single client (deemed to be the host) that would periodically broadcast its state to make it act as the source of truth. I tried this and got mixed results — well mostly terrible. It meant that the host was aggressively correcting the state of the other clients which led to a very “janky” experience.

I got around this by making the game sync every time a player snake eats the food. It’s by no means a perfect solution, but, it made sure that every time the food is eaten the players are realigned.

A potential solution would be the use of interpolation. This would involve tweening the current position of the snake to the one arriving from the server:

Example of tweening a graphic from one place to another

This might give a rubber banding effect however so it is not perfect.

Latency is a pain.

Power-ups 🔥

Now that I had a working multiplayer game, I thought of ways to make this even more interesting and stand out from single-player. Power-ups!

Thinking it through, all power-ups generally do in games is change the state or a particular constant. Think about a power-up that might make a character move faster. It would involve just updating the speed of the player to a higher value.

So I brained stormed some ideas and even used Instagram (via stories) to gauge some ideas from friends for power-ups. I’m yet to implement most of them but the two I’ve added thus far were “Freeze” and “Gun”.

Power-ups follow the same fundamental logic as placing food on the board except rather than increasing the score it would alter the game in some way.

Freeze ❄️

The freeze power freezes, or really slows down, all other players in the game. This involved creating an image that would represent the power and adding snake head hit detection logic (same as the food).

Once the power has been “hit” an event would be sent to the server and change the status of all other snakes to “frozen”. Once a snake has been detected to be frozen it would have its speed (how much distance the snake moves per tick) dramatically reduced.

There are some obvious improvements that can be done here like saving the length of the snake and maybe a different freeze “sound”.

Gun 🔫

Adding a gun and bullet mechanic was actually a bit more complicated than I first realised and require a lot of refactoring and thought. This power-up can be broken down into the following parts:

  1. The Gun power-up image and hit detection

This was really no different from developing the frozen power up apart from having a different image. This also turns the head of the snake into a bullet — to show other players when power-up is in effect.

2. A flash muzzle effect

I achieved this by placing a muzzle flare image where the snake head was and reducing the opacity to 0 in a short space of time.

3. The bullet itself and its traversal

Since the bullet when fired has to move independently from the snake, this meant I had to use a setInterval() to update the value of the bullet independently from the game loop.

In addition, I added hit detection so if the bullet hits another snake it will produce a collision sound effect (as well as eliminate the player that was hit)

4. A laser gun effect

Leveraging the knowledge I gained from drawing the AutoPilot grid lines, I used Canvas to draw a straight red line from the snake’s head. This is to help the player aim.

Single Player Highscore

I wanted to add a high-score component to make the single-player game more competitive.

This meant I needed external storage to store the results and fetch them when the component loads — once the game is over. I decided to use DynamoDB and leverage the AWS SDK to fetch and write data. This mostly worked seamlessly except for an annoying issue I came across that’s worth mentioning.

Troubles with Dynamo DB 😩

Due to the latest version of the SDK I was using, when the EC2 used role-based authentication, the Dynamo DB fetch request was taking around 4500ms. Yes, literally four and a half seconds. This is astronomically slow in the computing world unless you were trying to make an API request to the moon. This puzzled me for a while, as running locally produced sub 50ms responses.

After a lot of digging and debugging I finally came across this issue:

https://github.com/aws/aws-sdk-go/issues/2972

“by a recent security change to EC2 instances, where the Instance Metadata service will limit the number of hops a request can make. ”

Since my application was running in a Docker container this meant an extra hop within the EC2. I deduced that it was timing out with a retry of 1 second, which it tried three times — hence the 3 second plus response. Once it reached the max amount of retries it would retreat to using the older IMDSV1 instead of IMDSV2.

A potential temporary fix was to SSH into the instance and run the AWS CLI command: updatemodify-instance-metadata-options. This would increase the number of hops a request can make in an EC2 instance so it won’t time out. I tried that, and even that didn’t even work.

In the end, I just used an older version of the AWS SDK which made all of this go away. Is this an approach I would recommend? Probably not, but I decided not to spend any more time on this since the problem was solved without a significant consequence. Perhaps using ECR for the Docker container might have been an option to circumvent this.

Gamepad API 🎮

Whilst playing my PS4 I thought, why not integrate controller support. Adding this was actually surprisingly easy since I was able to leverage the native Gamepad API.

The Gamepad API introduces new events on the Window object for reading a gamepad or controller. This gives you the ability to poll for gamepad input and react to it — much like a keyboard event listener. For example, imagine pressing “down” on the d-pad on a PlayStation controller and that making the snake go down.

Buttons are trivial to map as they only have three states [not pressed, pressed, held down]. This means you can map each of these states to an action.

Analogue stick input works slightly differently and is represented by a floating-point value between -1.0 and 1.0. Depending on where the analogue stick is positioned it will change this floating-point number. For example, when the stick is pushed up it will set a value above 0.5. I leveraged this to map analogue input to a direction.

One browser security feature that needs to be noted is that for the gamepad to actually be detected, a user must interact with the gamepad once the page has been loaded. This helps prevent gamepads being used for fingerprinting the user.

An improvement to the UI to overcome this would be to have a prompt that gets the Player to press a button before the game starts.

Visualiser 🎊

The visualiser feature is a bit of an easter egg hidden in the settings. Simply put, I used the Wave.js library which takes an audio input and visualises it within a canvas element.

The library was straightforward to use so I integrated it into the background of the snake game. This is a totally over-the-top and unnecessary feature but it was a lot of fun!

This also gave reason to separate the Canvas layers within the game. As I was using an external library, it made sense to separate the layers of the game so the Canvases did not interfere with each other.

The bottom layer is the background colour, the second is the visualiser and the top is the game itself.

Testing

Testing a game is quite different from what I’ve experienced before. Unlike testing an API where you can check the results of an API call, a game “working” is less black and white.

The best way to really test the game (in addition to unit testing) was to use automated browser software to simulate a game and handle different scenarios. I made use of Puppeteer which spawns a chromium window and runs a sequence of keyboard and mouse inputs to simulate a game.

Puppeteer is quite fun to use. It’s like being able to snipe bids of the application and start interacting with it through commands.

Hosting

I hosted the application via an AWS EC2 instance. Given it’s not a static website (due to the multiplayer server and high score functionality) statically hosting on S3 was not an option.

This is a great tutorial on how to do this: https://medium.com/@balghazi/deploying-react-node-js-application-to-amazon-ec2-instance-a89140ab6aab

At this point I also Dockerised the application to make it easy to install and launch it on any given EC2 instance. I have two Docker containers, one for the client app and one for the server. I made use of docker-compose to run them at the same time.

Buying a domain

I used Route 53 to buy a domain and route it to my EC2 instance.

Yup. That’s it.

HTTPS and SSL

A standard HTTP website is flagged on most browsers as “insecure” and for the average user on the internet, it makes the website lack authenticity. This is where HTTPS comes in. Even though theres no sensitive data being transmitted, it’s worth having SSL just to add a level of legitimacy to the website.

My first step in accomplishing this was to generate a certificate via AWS Certificate Manager. I then used the certificate on an Application Load Balancer to route traffic to my EC2 instance. This was a relatively painless process except I misconfigured my target groups when port forwarding. This led to a whole weekend of debugging as I assumed I configured my application wrong. In the end, I actually misconfigured my security groups for the load balancer between the app.

A lesson learned. Double-check your security policies and ports!

AWS SSL System Architecture: https://snake.madusha.co.uk

Future

There are many ideas that I constantly think of to either improve or add to the game. Check them out here: https://trello.com/b/gNGZtVnf

Conclusion

In conclusion, for me, this was a fantastic large project which allowed me to develop a lot of skills, from front-end to back. Being both the Product Owner and the Engineer gave me a lot of freedom to create exactly what I envisioned in my mind.

When it comes to learning and development, in my opinion, watching a video or a tutorial only grants superficial understanding. To truly deepen or improve your skills, I believe you have to be practical and actually build something.

By putting myself in a position where I had to solve complex problems independently, my skills have significantly grown as Software Developer.

Thanks for reading and if you made it this far, try it out: https://snake.madusha.co.uk

--

--