DEV Community

Cover image for Making a Simple Game using HTML Canvas and JavaScript
Alexei Dulub
Alexei Dulub

Posted on

Making a Simple Game using HTML Canvas and JavaScript

Intro

Today we are going to take a look at how to use HTML5 Canvas and JavaScript to make a simple game that I made up. Now, I am sure this is not a totally original game by any means, but it is simple and straightforward. The basics of the game are that you have an undirected graph of nodes. One node starts with all of the values (let’s say they are sour cherry candies -- it's totally arbitrary) and we need to distribute all of the candies evenly to each node of the graph. For example if we have 8 nodes and 16 candies we will need to ensure that each node will receive two candies each.

Getting Started

I have taken the liberty of making some starting code for you so that we don’t have to work on all of the smallest details, and we can get to making a game faster on my github.

git clone https://github.com/alexei-dulub/canvas_demo_starter.git

We will be using http-server to help us serve our files. If you don’t already have it you can use the following to install it:

npm install http-server -g

Note: if you don't have npm either you can find out how here.

This will install everything necessary. Now to get it running you can simply run

http-server -p 80

You should now be able to connect to localhost in your browser which should be displaying the infamous ‘hello world’

But wait… what does this do?

Glad you asked. Let’s start from the foundation of everything -- the HTML.

<!DOCTYPE html>
 <html>
     <body>
        <script type='module' src='game.js'></script>
     </body>
 </html> 

Here we can see we have a very simple HTML skeleton that really only has one import line:

<script type='module' src='game.js'></script>

This line allows the web page to use the scripts we will be writing throughout the rest of this tutorial. Shall we look at those now? Let's start with the game.js file since it is the one we call to in the HTML:

 import { Logic } from './Logic.js'

 const l = new Logic() 

All this file is doing is kicking off the logic of our program by using ES6 imports so that we can create a new instance of the Logic class. We will look at what that means here in a second, but yeah, we could have done this in the Logic.js file imported here; however, if we had a more complicated usecase it is a good idea to separate our setup and our core game logic. So, let's see what we are importing here:

 export class Logic {
     constructor() {
        this.lastUpdate = performance.now()

        this.updateRate = 32

        this.canvas = document.createElement('canvas')
        this.ctx = this.canvas.getContext('2d')
        this.canvas.width = window.innerWidth
        this.canvas.height = window.innerHeight

        this.update = this.update.bind(this)

        document.body.insertBefore(this.canvas, document.body.childNodes[0])
        this.rAF = requestAnimationFrame(this.update)
     }

     update() {
        if (this.lastUpdate < performance.now() - this.updateRate) {
        this.lastUpdate = performance.now()

        this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
        this.ctx.font = '90px Arial'
        this.ctx.fillStyle = 'black'
        this.ctx.fillText('hello world', 0, 90)
        }
        this.rAF = requestAnimationFrame(this.update)
     }
 }

Note: this.update = this.update.bind(this) allows us to call this inside of update()

Here first thing you can see is that we are exporting this class. This is what allows us to import it as we saw was the case in game.js. Next we have the constructor() function which goes all of the initialization of the game logic to run. What is really to note is the following line:

this.canvas = document.createElement('canvas')
this.ctx = this.canvas.getContext('2d')
this.canvas.width = window.innerWidth
this.canvas.height = window.innerHeight

What we are seeing in these few lines is firstly the creation of the HTML5 Canvas we will be using as our art medium for the duration of this tutorial (and if I have done my part properly for time to come). If you recall there was no <canvas> tag in the HTML we made. That's because we made it here!

Next you will see that we are making use of our newly created canvas to getContext and said context will be 2 dimensional. The '2d' part isn't important right now, but I'm sure you can probably guess what it's doing. Then we make use of some built-in JavaScript attributes by setting canvas width and height to that of our browser window.

Note: we will not be covering resizing here so make sure that you reload if your canvas ever looks funny.

Lastly we need to insert the new element into the HTML and we do so with the following:

document.body.insertBefore(this.canvas, document.body.childNodes[0])

Now that we have a canvas to play with we can start to examine how we are able to print 'Hello PixelPlex' to the canvas.

update() {
    if (this.lastUpdate < performance.now() - this.updateRate) {
    this.lastUpdate = performance.now()

    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
    this.ctx.font = '90px Arial'
    this.ctx.fillStyle = 'black'
    this.ctx.fillText('Hello PixelPlex', 0, 90)
    }
    this.rAF = requestAnimationFrame(this.update)
}

This update() function is the proverbial heart of our logic as it is what pumps life into any sort of games or animations we create on canvas. While the game we are making today doesn't have a lot of animation (none really) that can easily be changed since we have already given our game this loop.

What is happening here is we have an if statement that is checking whether it is time for the script to have canvas render all of the things we want to render. This is how we can manager the smoothness and timing of things in canvas and you can just think of it for now as our 'frames per second.' So, when the statement evaluates to true is when we can render new stuff. This is when the canvas tutorial really begins!

this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
this.ctx.font = '90px Arial'
this.ctx.fillStyle = 'black'
this.ctx.fillText('hello PixelPlex', 0, 90)

Let's start by addressing that we are using the context we got earlier in the constructor to do our work. Any changes you want to make to the canvas are really done to its context and not to the canvas directly. And you can see that the first thing we are doing is clearing a rectangle starting at the points (0, 0) and the size of it is the same size as the canvas. In other words we cleared the entire screen. This is very important as canvas does not do this for you. If you neglect this line you will notice lots of overlap of elements and things especially when you make more complex things.

The next few lines are more straightforward. You can see we are setting the font and then the fillStyle (which really just means what color you want to fill with), and the lastly we use the fillText function that is given what the text will be as well as an (x, y) to place the bottom left corner of the text. Notice the bolding there. Placing things in canvas is an art and it will be confusing at first when you use this function but it might not be showing up. That could be because of which part canvas uses of the text to place at your desired (x, y) so just be wary.

The last thing to cover before we can get started is the last line we see in our update() function:

this.rAF = requestAnimationFrame(this.update)

The sharp of sight out there may notice that this line does not only come from the update() function, but also the constructor() as well... The reason we find it in the constructor() is because what this line does is start the entire loop process. That is why we pass is the this.update since we are wanting to use that function for each animation frame. This is also the same reason it is call every time at the end of the update() function (regardless of the evaluation of the if statement). We call it an animation loop and it can't be a loop if it doesn't loop, right? Basically we need to call the update() function at the end of the update() function so that it is called over and over again until the end of time or we leave the page. All of that combined is what gives us that foundation of a game using HTML Canvas!

Now... that was a lot if you have never done any of that and are still with me. Pat yourself on the back, and take a quick break by playing around with some of the values in either the update() (e.g. play around with where the text is rendered or what it says or what color it is!) and maybe play around with something in the constructor (e.g. what happens if you change the updateRate higher or lower?). Try some of those things out, and I will see you in the second half of the tutorial!

We'll Start Making a Game Now, I Promise

As a recap we are working on making a game that involves distributing value amongst a set of nodes (in our case 8). The nodes are connected to make an undirected graph meaning that the value can flow from either connected node. So, let's start by getting some nodes on our canvas, shall we?

export class Node {
    constructor(ctx, x, y) {
        this.ctx = ctx
        this.x = x
        this.y = y
        this.fill = 'red'
        this.size = 50
    }

    draw() {
        this.ctx.fillStyle = this.fill
        this.ctx.beginPath()
        this.ctx.rect(this.x, this.y, this.size, this.size)
        this.ctx.fill()
    }
} 

We will start by making a new Node class to use. This class will serve as a place for us to implement any functionality we want the nodes to have later on. We see familiar elements such as the constructor() but something that is a little different here is the draw() function. This is what is to be called inside of the update() function in our animation loop. draw() is where we define how we want the node to look, and if we want that look to be dynamic we use lots of variables and calls to attributes of this so as a Node instance changes it will be reflected every time a new frame is rendered. In our draw we are drawing a red rectangle. The process is similar to drawing the 'hello world' from earlier. Since this is supposed to be called in the update() function of our Logic let's add that now.

update() {
    if (this.lastUpdate < performance.now() - this.updateRate) {
        this.lastUpdate = performance.now()

        this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)

        let node = new Node(this.ctx, 200, 200)
        node.draw()
    }
    this.rAF = requestAnimationFrame(this.update);
}

The update() function in Logic.js has replaced the text with making a new Node instance and then calling that instance's draw() function. Now we should see one singular node (red rectangle) at (200, 200). But we need 7 more nodes in order to make our graph, so let's change that.

import { Node } from './Node.js'

constructor() {
    ...;

    this.nodes = []    

    ...;

    this.makeGraph()

    ...;
}

makeGraph() {
    for(let x = 0; x < 8; x++) {
        this.nodes.push(new Node(this.ctx, 100*x, 100))
    }
}

Note: Only the changes to the constructor() are show in the snippet above.

In the constructor() we have added a new nodes attribute for the Logic to keep track of, and then we made a new function which gets called after the creation of the array. In makeGraph() we are using a for loop to add 8 nodes to the array using the counter to change each one's location for visual verification (we can see them all).
Now we can change the drawing of one node to draw all eight of our freshly made nodes as follows:

update() {
    ...;

    this.nodes.forEach(node => {
        node.draw()
        return
    })

    ...;
}

Remeber the Unit Circle?

So, I won't quizzing on the Unit Circle per se, but we will be dusting off some trigonometry and using JavaScript's built in Math library. The formation we have for the nodes right now is all fine and dandy, but it wouldn't really make for a good graph so lets work on arranging the nodes in a circle so we can make some cool patterns later on.

makeGraph() {
    let x = this.canvas.width/2
    let y = this.canvas.height/2
    let angle = 360/8
    for(let i = 1; i <= 8; i++) {
        let nX = x + this.radius * Math.cos((angle*i)*Math.PI/180)
        let nY = y + this.radius * Math.sin((angle*i)*Math.PI/180)
        this.nodes.push(new Node(this.ctx, nX, nY))
    }
}

Note: this.radius is defined in the constructor() as this.radius=200

Above is our new example of the makeGraph() found in Logic.js. This will distribute 8 nodes evenly across a circle (and with some minor modifications it can take a dynamic number of nodes and still spread them evenly!). We start by locating the center of the canvas. We then divide 360 (degrees) by the number of nodes we would like to create. Then we have the for loop like before, but this time we the angle of the respective node (angle*i) converted into radians (*Math.PI/180) and then find the cos/sin and multiply it by the radius of 200. Then we add that value to the x/y to offset it from the center. These calculated values are then used as the location for each node. This functions will become really familiar if you continue to make more complex things in canvas especially of it involves rotation, and it makes for an easy was for something else on the canvas to track the player such as an enemy if it knows the angle between the player and itself. But that's something for another day.

Connecting the Dots, I mean, Nodes...

Awesome, now that we have our nodes displaying in a somewhat interesting formation, let's connect them both visually and in memory. We will start by adding the following to the constructor():

this.connections = []

This will help us keep track of each node's connections later on when we start to transfer values. To start making use of this we will make the following function in our Node class:

addConnection(connection) {
    this.connections.push(connection)
}

Simple but so elegant. All we are doing here is adding a Node to our connections collection (say that five times fast) so that we can operate on it later. Now that we have the Nodes connected via memory let's start to visually connect them on the canvas for our player.

getX() {
    return this.x
}

getY() {
    return this.y
}

drawConnection(connection) {
    this.ctx.beginPath()
    this.ctx.moveTo(this.x+(this.size/2), this.y+(this.size/2))
    this.ctx.lineTo(connection.getX()+(this.size/2), connection.getY()+(this.size/2))
    this.ctx.stroke()
}

drawNode() {
    this.ctx.beginPath()
    this.ctx.rect(this.x, this.y, this.size, this.size)
    this.ctx.fill()
}

Note: Changing the fill will come right after this part

This round we have added four new functions to our Node class. The first two simply return the x or y of the respective node, and you can see why when we examine the third function of drawConnection(). This is using the same draw pattern we have already seen with canvas but all it is draw is a straight, black line from our node to the connected node. And as you can see it is using the getX() and getY() we made too. Neat!

Lastly is a drawNode() function which is purely for consistence and clarity. Since we are drawing the connections in their own function I thought it would make sense as well as look better to have the node be drawn in a separate function. You will find when you work on more complex projects that piecing our the rendering will make it easier to find when you want to make a change but the one class may have five moving parts and all of the rendering looks so similar it's hard to find what you're looking for. Sorry for the run on sentence, and no, I've never written anything messy...

That then brings us to what changes in the main draw() function now. With the above changes it looks like the following:

draw() {
    this.connections.forEach(connection => {
        this.drawConnection(connection)
    })
    this.drawNode()
}

It comes down to a simple forEach loop on all of the Node's connections and then calling our newly created drawNode() function. So, now that we have given the nodes the ability to make connections and draw them let's leverage that in our Logic.

constructor() {
    ...;

    for(let i = 0; i < 8; i++) {
        this.nodes[i].addConnection(this.nodes[1])
        this.nodes[1].addConnection(this.nodes[i])
        this.nodes[i].addConnection(this.nodes[2])
        this.nodes[2].addConnection(this.nodes[i])
    }
}

What we have here is a for loop at the end of our constructor() and it is calling the addConnection function we just made. You may notice we are calling it twice for each relationship (simply swapping the index on the array). This is because we are making an undirected graph so we need both nodes to be aware of their mutual relationship; this is important. Now we should see that we have our nodes and they are connected by lines. Don't worry too much for now if some of the line appear to be over or under the nodes. This has to do with rendering order, and we won't focus on it today.

Red Light, Green Light

Alright, we have a graph. How about we give the nodes a little more functionality since this is supposed to be a game after all. We will start by giving the player some sort of indication that they are heading in the correct direction? Let's have the nodes change color as they get closer to the solution of the puzzle.

constructor(ctx, x, y, ideal) {
    ...;

    this.value = 0
    this.idealValue = ideal
}

Here we are changing the constructor() slightly so that we can tell the nodes what their ideal value will be, and we also have to track the node's journey to this ideal value by having a value attribute that is representative to what the player is trying to balance. And the astute out there will notice that this also means we will have to change how we make the nodes back in the Logic.js file.

this.nodes.push(new Node(this.ctx, nX, nY, 2))

So, now that we have an idea of what value we want as well as what value we are currently at let's change the node's fill based on this new information:

setFill() {
    if(this.value/this.idealValue < 0.33) {
        this.ctx.fillStyle = 'red'
    }
    else if(this.value/this.idealValue > 0.33 && this.value/this.idealValue < 0.66) {
        this.ctx.fillStyle = 'orange'
    }
    else if(this.value/this.idealValue > 0.66 && this.value/this.idealValue < 1) {
        this.ctx.fillStyle = 'yellow'
    }
    else if(this.value/this.idealValue === 1) {
        this.ctx.fillStyle = 'green'
    }
    else if(this.value/this.idealValue > 1) {
        this.ctx.fillStyle = 'purple'
    }
}

What we are doing here is a series of if statements that are looking to see what value the ratio the node's value is compared to its ideal value. So, if it is below 33% the node is red, between 33% and 66% it's orange, between 66% and 100% yellow (you're getting close), and if it is 100% meaning that the value is the ideal value then it will turn green. If it is over 100% meaning the node has too much value it is purple. Feel free to make your own color schema or even explore a way to make the coloring more gradual.

Now to make use fo this all we have to do is call setFill() in the drawNode() function.

drawNode() {
    this.setFill()

    ...;
}

The way things are set up right now, the game can never be won (unless the ideal is zero then ¯\_(ツ)_/¯) but we will need a function so that we can at least set the value of one node to have enough value to solve the puzzle. In the Node class we make the following:

setValue(val) {
    this.value = val
}

Then in the Logic class's constructor() we have the following line after the loop making the connections:

this.nodes[0].setValue(16)

Now with all of that we should have mostly red nodes, but one of them will be purple since it 800% of the value it should.

console.log('click!')

Now that we have most of our visuals set up we can start to add the controls for the player to interact with our masterpiece. We will start by editing the Logic class. Let's add the following two items to the constructor():

this.selectedNode = null

this.handleClick = this.handleClick.bind(this)

window.addEventListener('click', this.handleClick)
this.canvas.addEventListener('contextmenu', this.handleClick)

We've seen the binding to this, but something that is new is the addEventListener. This is built-in JavaScript, and it allows us to do what JavaScript does best: respond to events happening. What we are responding to here is the 'click' event as in the click of a mouse on our mouse on the browser window. Similarly we listen for the 'contextmenu' event on the canvas. What is that event you ask? It just means a right click on the canvas. If we didn't do this we wouldn't be able to right click. Okay, cool, but handleClick isn't a function of Logic... yet.

handleClick(e) {
    let x = e.clientX
    let y = e.clientY

    if (e.button === 0) {
        this.nodes.forEach(node => {
            if (node.wasClicked(x, y)) {
                let selected = this.nodes.filter(n => n.isSelected)
                let toDeselect = selected[0] ? selected[0] : null
                if (toDeselect) toDeselect.deselect()
                node.select()
                this.selectedNode = node
            }
        })
    }
    else if (e.button === 2) {
        this.nodes.forEach(node => {
            if (node.wasClicked(x, y)) {
                if (this.selectedNode.getValue() > 0 && 
                    this.selectedNode.isConnection(node)) {
                         node.incrementValue()
                         this.selectedNode.decrementValue()
                     }
            }
        })
    }
}

In this function we are using the the event object passed to our function (an effect of adding an event listener) so we can know exactly where the user clicked on the window. We then have an if statement that will check where it was a left (0) or a right (2) click. If it is a left click we check to see if any of the nodes were selected (more later on). If one was clicked then we deselect the currently selected node, and make the clicked node the selected node. This is our functionality for selecting which node to transfer value from!

When it's a right click we see if a node was clicked. If one was clicked we then check if the selected node even has value to give and if so is the clicked node is a connection of the selected node. If a node passes all of these check the clicked node's value gets increased and the selected node's value will decrease. A transfer of values!

We have this logic implemented in, well, the Logic class but there were a lot of functions in there that the Node doesn't have. Let's change that. We will start by changing the Node's constructor() one more time.

constructor(id, ctx, x, y, ideal) {
    this.id = id

    this.isSelected = false

    ...;
}

getId() {
    return this.id
}

deselect() {
    this.isSelected = false
}

In order to better keep track of our connections we will need to give the nodes IDs and we will see the in a bit. And naturally that means we will have to also change where we create all of the nodes

this.nodes.push(new Node(i, this.ctx, nX, nY, 2))

Note: remember that this is in the for loop that declares all of the nodes so i is the value from the loop.

Next are mostly simple data manipulation functions:

getValue() {
    return this.value
}

decrementValue() {
    this.value -= 1
    console.log(this.value)
}

incrementValue() {
    this.value += 1
    console.log(this.value)
}

isConnection(node) {
    return this.connections.filter(c => c.getId() === node.getId()).length === 1
}

The only thing worth noting from this block is the isConnection() function where we are returning a boolean by filtering the connections a node has which will return a new array with any values that evaluate to true based on the statement given as a parameter. We then compare the length of this 'returned' array (of which we don't actually assign it to) and of that length is 1 it means that the node passed to the function is truly a connection of the current node which results in the return of a true otherwise a false is returned.

But How Do I Win?

We are almost there! But we need to make sure that the player knows that they have won. We will start by adding one final function to our Node class:

isSatisfied() {
    return this.value/this.idealValue === 1
}

This will make sure we can check that all of our nodes are happy, because when they are we have achieved the win state. Let's make the Logic aware of that now by changing the update() function:

update() {
    let playerWon = true
    if (this.lastUpdate < performance.now() - this.updateRate) {
        this.lastUpdate = performance.now()

        this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)

        this.nodes.forEach(node => {
            if (playerWon) {
                playerWon = node.isSatisfied()
            }
            node.draw()
        })
        if (!playerWon) {
            this.ctx.fillStyle = 'black'
            this.ctx.font = "90px Arial"
            this.ctx.fillText("You Won!", this.canvas.width*.41, this.canvas.height*.1)
        }
        this.ctx.fillStyle = 'black'
        this.ctx.font = "30px Arial"
        this.ctx.fillText("Left Click to select a node. Right Click on a node to transfer value from the selected node. Try to turn all of the nodes green.", this.canvas.width*.18, this.canvas.height*.95)
    }
    this.rAF = requestAnimationFrame(this.update);
}

You can see that when we are rendering each node we also check if they are satisfied. If one node does not meet that qualification it will make the playerWon variable false meaning it will not display the win message. Conversely if all of the nodes are satisfied then it will remain true allowing for the message to be rendered thus informing the user of their victory. You will also notice a message that is alway rendered at the bottom so that we can give the user a bit of guidance on the controls. Feel free to change the message to what you like.

Conclusion

And that is that! We have made a game using JavaScript and HTML5 Canvas, and that was really only the surface. We covered design, trig, programming practices, and lots of other JavaScripts tid bits. I hope this was a fun and enjoyable experience for you, and that you were also successful and inspired in creating this alongside the tutorial. If there were some bumps you can always check the finished version on my github. You can also check the commit history to examine my thought process it, and you will find that it closely resembles this tutorial.

Top comments (1)

Collapse
 
rakibsardar profile image
Rakib sardar

Hi am Rakib sardar and I creating a Javascript simple game developing library, if you want to work with this project