Last year I've worked on a project where I had to develop a small game alongside other components. But recently I've decided to isolate this game as a single stand-alone project since it was a good way to share a bit about canvas development and possibly introduce someone that Canvas API.
This article is intended to detail the development process of the game and some fundamentals of how to deal with Canvas API for similar projects.
Before jumping into coding, we must have some things clear in mind, such as the concept of the game, the goal, how to win, and how to lose. If you have played it already, you've probably got it, but here is a quick overview:
The game consists of a population of 54 people separated in a grid system that keeps them apart from each other. As the game starts, 2 random people are infected by a disease. Infected people tend to infect the nearby population by randomly choosing what neighbors they will try to infect and at what speed the disease will reach them. By clicking at the healthy person we can vaccinate them, allowing them to become immune to the disease. The goal is to trap the disease right at the start, preventing it to spread further through the population and then vaccinate all remaining healthy people. I also added a timer of 30 seconds to make things a little bit more interesting.
Let's start by setting up our Canvas environment. For this project, I've chosed the framework NuxtJS to work with VueJS to handle all the interface interactions and also the engine responsible to create the triggers we will be needing later. After this quick introduction, let's start!
The first thing - and the most obvious one - is to "create a NuxtJS project". I set it up with nothing different from a simple project, with an index page, global style, and a single component called "Game" to manage all functionalities and interactions.
All HTML needed was created and styled at the Game component. But again, one thing is an important note here. Our game must have some "sections", which are the steps where the player will be. If we take a look that the player's journey, it starts at a welcome page, then the game starts and after the timer goes out (or the player vaccinate all the population), it has two possible endings, they will win, or lose. These steps are what we called "sections" here.
"requestAnimationFrame() is the method from Web API that tells the browser that you wish to perform an animation and requests that the browser calls a specified function to update an animation [...]" _ by MDN Web Docs
Now a non-technical explanation:
"It calls a function over and over again for literally every single frame, and this function will draw an updated image onto the canvas with minor differences from the previous frame, simulating the idea of movement."
Now let's do this at our Game component. We create a canvas HTML tag and a VueJS method called animate(), and the first thing this method does is to "request-animation-frame" pointing itself as the argument so this function can be called for every frame.
This is the initial setup for all canvas development. From now on we can start looking at our project specifically, but all the concepts that will be detailed here can be applied to different scenarios.
As you may have noticed, we will need to create a grid to display the population, with lines and columns. At this point, we need to start talking about the basics of what we can draw on a canvas.
The draw() function that we talked about earlier receive as the first argument a rendering context, this context is an object with properties and methods that you can use to render graphics inside the canvas element, such as Lines (that can be curved or straight) and Circles. For our game, these are the only two shapes we will be using - since the game is a bunch of lines and circles.
We already have the canvas width and the height values that we used to style it. But now, to create a grid system, we just need to use the power of math to get the starting-point and the ending-point for each line and column. These points are specified as coordinates related to the top side (X-axis) and left side (Y-axis) of the canvas, and that's the part where we start looking at the canvas as a Cartesian Coordinate System that will guide us throughout the next steps.
This is one of the most interesting parts. As we already defined as the game scope, every person has a predefined (and identical) way to behave and interact with each other, but here is a reminder:
"[...] Infected people tend to infect the nearby population by randomly choosing what neighbors they will try to infect and at what speed the disease will reach them. [...]"
When we have a situation like this, the best decision to make is to deal with Classes - where a single structure can have multiple instances.
At this draw() method we must create the shapes that will form the face, and this "face" consists of three simple elements (the head, left eye, right eye, and mouth). And since we received the center coordinates for the person at the constructor(), we can again use the power of math to draw all three elements and place them related to this center point.
It's important to detail here that some aspects of the face will vary based on the person's state property, like the color that will be blue for healthy people, red for infected people, and yellow for the vaccinated ones.
For now, we must also have in mind that all the population will be instantiated from the Game component, once we have a basic structure for it to work. In fact, thats exactly the next step...
Now that the Person class has a simple structure, we can instantiate the population at the Game component. This process will take a bit to finish since this is the moment we need to define no only the center coordinates for each person, but also randomly define the directions it's going to infect its neighbors and randomly define the spreading speed for each "disease arm".
There is also one thing that must be clear here. If you recall one detail of the game scope, each person will be able to infect their neighbors by trying to infect them once the disease has reached them. The mechanic behind it is simple: "if the disease arm reaches the neighbor, and it's not vaccinated yet, the neighbor will turn into an infected individual". To create this logic, two things will be needed: the first is that at the Person class we will create a function able to try to infect the current person, and the second thing is that for each person of the population we will need to store the instances of its surrounding neighbors so we can trigger this tryToInfect() method once the disease reaches them.
Mathematics starts to have a bigger role here. If you ever thought that you would never use the Pythagorean Theorem in your life, I'll try to convince you otherwise. Looking to a single person, they can try to infect their neighbors in 8 different directions (top, top-right, right, bottom-right, bottom, bottom-left, left, top-left).
This article wont turn into an Algebra class, but if you think about it for long enough you will start to see some triangles being formed to define all the 8 arms of the disease related to the center of the face and two close neighbors. The principle that needs to be mentioned is that for each one of these arms we must have stored all the way-points between the center and neighbor's edge in an array so we can control the arm movement and its speed until it reaches the neighbor and try to infect them. And to accomplish that, there isn't much we can do besides applying some algebra formulas to get and store the values.
Now it's time to create the interaction that will wait for the player to click/tap at some person, and the behavior to apply the vaccine that will be triggered with this interaction.
First I created a method at the Person class called applyVaccine(). The idea behind it is also simple: "if the person is not 'infected', change its state to 'vaccinated'".
After creating this method we can create the event listener to wait for the player's interaction to trigger the applyVaccine() method. The trigger can be built receiving the coordinates from the mouse position related to the canvas element, and these coordinates must be compared with the existing center point from every person instantiated. And if the difference between these two points is smaller than the radio of the head circle, the player clicked at a person.
We are getting to the end. Now we reached a point where the "soul of the game" is already created, the main interactions (witch are the functions we've defined at the Game component) and behaviors (which are the methods created at the Person class), we can focus some effort at the smaller things, such as the scoreboard, the timer, and sound effect management.
We stored all instances of the Person class, and with this list we can easily retrieve the current state of each one of them, calculate its percentage, and display it on the Scoreboard. It's always important to remember that for all functions that we want to run for each frame, it must be executed at the animate() method, and with the Scoreboard update, it's no different.
Sounds effects can be easily implemented using Howler.js, an awesome library able to manage mp3 files in a reliable way across all platforms. It works in a similar way as GreenSock, we instantiate the audios, and play/pause/restart them whenever it's needed.
Working with Canvas API usually requires more than we initially think, but between all the math, rules, and exceptions we create, the logic of the game can be found as a simple and straightforward storyline, just like it was described at the beginning of this post.
When looking for the final project I wouldn't say it was easy to develop, there were a ton of problems along the way, crashes, conflicts, things that I initially had no idea how to fix, but as I said at an old article:
"Start from the basics, recognize how the next step looks like, and work on it. Problems are inevitable and that's what makes each project unique in some way, and winning these small battles is one of the things that motivate us to go to the next one." _ from What if LinkedIn was beautiful?
That's all, everyone. If you made this far, congratulations, and thank you for reading. And also, feel free to connect with me on LinkedIn.