Module 9: Randomization, collision detection, and score
Objective: explore JS's Math library, create utility functions and optimize code.
- Wrapping up the block dodger
- Randomize it!
- More random numbers
- Collision detection
- Optimization
- Last (but not least), adding score
- Next steps
Wrapping up the block dodger
Woohoo! Here we are at module 9, the penultimate module of the course, and the one where we wrap up the block dodger game! Believe it or not, but we've covered all of the major points of basic programming, so this last module is less about introducing new concepts and more about practicing what we've already learned.
As you've noticed, we still have a few missing pieces standing in the way between where we are now and a full-blown block dodger, including generation of obstacle blocks, collision detection between obstacles and the player box, and a player score. We'll handle these features by making use of JS's built-in Math library, a new array function, and adding a few additional functions to keep our code organized.
Randomize it!
Random numbers are very important in game design (and programming in general!). They're an
easy way to reduce predictability, simulate chance, and add a natural feel to applications.
Most programming languages have a variety of handy ways to get random numbers, but JS
offers fairly slim pickings: Math.random()
, which returns a random
decimal between 0 and 1. Let's put this to use in generating our falling obstacles:
instead of hard-coding three obstacles to appear in the init()
function as we did...
// Add a few obstacles to the array
obstacles.push(new Obstacle(10, 0, 1, 30, "#5959ee"));
obstacles.push(new Obstacle(100, 0, 1, 20, "#9999ff"));
obstacles.push(new Obstacle(200, 0, 2, 25, "#8877ef"));
...we'll remove those lines and head over to the update()
function, where
we'll use a conditional and Math.random()
to determine whether,
at a given frame, a new obstacle
gets created or not:
// Generate a new obstacle every so often
if (Math.random() > 0.98) {
obstacles.push(new Obstacle(canvas.width / 2, -20, 2, 20, "#8877ef"));
}
If my math is correct, this works out to about a 1 percent chance that on any given frame,
an obstacle will be created. Try adjusting the 0.98
number and see
how the frequency of obstacle generation changes--it's pretty sensitive, so consider
adding decimal places for better precision.
See the Pen block dodger (part 8) by ggorlen (@ggorlen) on CodePen.
More random numbers
Now that we're using random numbers, let's take a moment to add a utility function to our program to produce random integers between two bounds. Random integers will be useful for randomizing the placement and properties of our obstacles. Utility functions are very general and reusable functions that handle simple tasks, saving a lot of trouble. The below function takes a range of input between a low and high number, and returns a random whole number between the two. Technically, the lower bound is inclusive and the upper bound is exclusive, meaning it's possible for the function to return the low number but not the high number.
// Returns a random number between two bounds
function randInt(lo, hi) {
return Math.floor(Math.random() * (hi - lo) + lo);
}
If you're curious, the way this works is multiplying Math.random()
by an integer x
produces a random decimal between 0 and x. Subtracting the lower bound from the upper bound
before this operation, then re-adding the lower bound to the result puts the number
in the correct range. At this point, the number is still a decimal, so Math.floor()
is used
to chop off that decimal. Try it out with randInt(5, 15);
and test the results.
But honestly, the details aren't too important here: it works; use it! Most programming languages offer this function as a standard library feature, so you wouldn't have to worry about the implementation details in those languages--don't sweat them here either!
Let's put this function to use! But before we do, we're about to be making a mess in our nice,
clean update()
function. It's a good idea to extrapolate this burgeoning
obstacle-making code to a separate function, makeObstacle()
, keeping
the dirty details away from update()
.
// Generates some random values for an Obstacle and
// adds the new Obstacle to the obstacles array
function makeObstacle() {
let size = randInt(10, 20);
let x = randInt(0, canvas.width - size);
let y = 0 - size;
let color = "hsl(200, " + randInt(0, 100) + "%, 60%)";
let vy = (Math.random() + 1) * 30 / size;
obstacles.push(new Obstacle(x, y, vy, size, color));
}
The above code looks complex, but all of the numbers and operations were found through old fashioned trial
and error; you'll come up with your own tricks for finding the right "feel" for these kinds of things,
which is why I'm endlessly encouraging you to tweak my examples (go ahead, play with the above
code--when it gets too complex, revert to simple literal numbers!).
For example, I wanted smaller objects to move faster than larger objects, so I made the velocity inversely
proportional to size. The color
variable uses hsl()
, which
is just another way to represent color; check the explanation
here to learn more.
All right; now, we'll call makeObstacle()
from the update()
function, and we've got ourselves a pretty nifty looking near-game:
See the Pen block dodger (part 9) by ggorlen (@ggorlen) on CodePen.
Collision detection
Next, we really need collision detection, or some function that tells us if two boxes are overlapping. Collision detection can be a very complex topic, but luckily, it's pretty straightforward as it relates to rectangles. The idea is to check whether each rectangle's x coordinate is between the other rectangle's bounds on the x axis and repeat the process for the y axis. If it passes tests on both axes, they're intersecting somewhere. Here's the code:
// Returns true if a and b rectangles
// are colliding, false otherwise
function collides(a, b) {
return a.x <= b.x + b.size &&
b.x <= a.x + a.size &&
a.y <= b.y + b.size &&
b.y <= a.y + b.size;
}
Looking at this for too long can make your head hurt, and it's written here a little less for readability and more for succinctness; this sort of stuff is best handled by a game framework with pre-written complex collision detection algorithms, by searching a programming help or reference websites like Stack Overflow, or avoidng games that have heavy amounts of collision detection and physics, such as platformers and shooters (at least for your first project). That is, assuming you're not a math buff (power to you if you are!). In the meantime, just plug in the one above and enjoy!
And, of course, we need to actually make a call to the collides()
function
and send it two boxes. But where? This is a great job for the loop we're using to move and render our
obstacles--since we're already doing that, we'll just add a collision check inside the loop
and re-initialize the game if a collision occurs:
// Check for a collision between the player and this obstacle.
// Reinitialize the game if there was a collision.
if (collides(player, obstacles[i])) {
init();
break;
}
The break;
statement simply ends the for loop immediately; once a new game
has been set up in the init()
function, there will no longer be any obstacles
in the newly-cleared obstacles
array, and the game will crash (we'll talk more about bugs in the next module). Let's take a look
at the whole update()
function since it's quite large (it's also visible in the
Codepen below). In all honesty, it might be high time to split this function into smaller helper
functions, but we'll call it sufficient for now.
// Updates the animation state and draws a frame
function update() {
// Generate a new obstacle every so often
if (Math.random() > 0.95) {
makeObstacle();
}
// Clear the entire canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Update the position of the player
player.move(mouseX);
// Draw the player box
ctx.fillStyle = player.color;
ctx.fillRect(player.x, player.y, player.size, player.size);
// Update and draw the obstacles
for (let i = 0; i < obstacles.length; i++) {
// Update this obstacle's position
obstacles[i].move();
// Check for a collision between the player and this obstacle.
// Reinitialize the game if there was a collision.
if (collides(player, obstacles[i])) {
init();
break;
}
// Draw the obstacle
ctx.fillStyle = obstacles[i].color;
ctx.fillRect(obstacles[i].x, obstacles[i].y,
obstacles[i].size, obstacles[i].size);
}
// Ask the browser to move on to the next frame
requestAnimationFrame(update);
}
At this point, try playing the game--I think it's pretty close to satisfactory!
See the Pen block dodger (part 10) by ggorlen (@ggorlen) on CodePen.
Optimization
There are only two remaining things we should take care of before calling this game done. One is a classic arcade game feature whose importance cannot be overstated: score! It's a simple addition, but putting a number on the player's performance adds a competitive feel and an addictive quality that makes it more than worth the small amount of trouble.
The second feature to think about is a very subtle optimization issue. What happens to obstacles that
fly below the bottom of the screen? To find out if they're still there, we can add a console.log()
statement and print the length of our obstacles
array every time we make a new one:
// Generates some random values for an Obstacle and
// adds the new Obstacle to the obstacles array
function makeObstacle() {
let size = randInt(10, 20);
let x = randInt(0, canvas.width - size);
let y = 0 - size;
let color = "hsl(200, " + randInt(0, 100) + "%, 60%)";
let vy = (Math.random() + 1) * 30 / size;
obstacles.push(new Obstacle(x, y, vy, size, color));
console.log(obstacles.length);
}
If you're skilled enough at the game, you'll notice that the longer the player stays alive, the larger the
obstacles
array grows. If the player is really good, there could be hundreds or
thousands of items in this array, every one of which is being moved, rendered (offscreen, mostly), and
checked for collisions.
All of this extra work can slow your game down and bring the player's fun to a grinding halt. Luckily, the issue isn't too problematic in this simple game--the player will almost always lose before it becomes a problem and modern computers can handle these calculations well. Nonetheless, it illustrates a typical efficiency problem programmers face constantly, so let's go ahead and resolve it by removing obstacles from the array as soon as they pass the bottom of the screen. Coincidentally, this is also where I'd like to increase the player's score, which can be thought of as the number of obstacles the player has successfully dodged (you could also make the score time-based, for example).
JS offers many functions to remove an item from an array, pop()
being the most
common (it removes and returns the last item in the array). However, this won't help in our case,
since the obstacle that just flew off screen could be anywhere in the array, so we'll have to resort
to a somewhat more technical function, splice()
, which takes two number parameters,
an index to begin removal at and the number of elements to remove starting from that index.
splice()
also allows for adding items to the array in the same call, but we'll ignore
those optional features. As always, have a look
at a reference site, such as this one,
to learn about what these methods are and how they work--memorization isn't necessary!
Another subtle thing to note is that inside of a loop, if we splice out an item, the loop counter variable
i
will be off by one and miss updating the next item in the loop. We could
adjust this manually, but my preferred approach is to simply run the for loop backwards; we'll initialize
the i
index variable to the length of the array - 1, which points to the last
item in the array, and count backwards to 0. This way, if any element in the array that
i
points to is removed, no problem; i
will still
point to the correct item on the next iteration:
for (let i = obstacles.length - 1; i >= 0; i--) {
// etc etc ...
}
And another handy point: we've got a lot of code in our for loop that refers to obstacles[i]
,
so let's beautify our code by saving this current array element in a variable called o
(short for
obstacle) at the beginning of each iteration of the loop:
// Update and draw the obstacles
for (let i = obstacles.length - 1; i >= 0; i--) {
// Save the obstacle in a variable
let o = obstacles[i];
// etc etc ...
}
Now, anywhere in our code that refers to obstacles[i]
in this loop can be replaced
with simply o
. Let's put it together; here's the full loop body as it stands,
complete with code to remove any off-screen obstacles:
// Update and draw the obstacles
for (let i = obstacles.length - 1; i >= 0; i--) {
// Save the obstacle in a variable
let o = obstacles[i];
// Update this obstacle's position
o.move();
// Draw the obstacle
ctx.fillStyle = o.color;
ctx.fillRect(o.x, o.y, o.size, o.size);
// Remove this obstacle from the obstacles array if off screen
if (o.y - o.size > canvas.height) {
obstacles.splice(i, 1);
}
// Check for a collision between the player and this obstacle.
// Reinitialize the game if there was a collision.
else if (collides(player, o)) {
init();
break;
}
}
Again, this loop code is getting a little large for my taste, but I'll leave refactoring it into its own
function, maybe updateObstacle()
, perhaps, for the reader.
See the Pen block dodger (part 11) by ggorlen (@ggorlen) on CodePen.
Last (but not least), adding score
We're almost there! To add a score, we need a--you guessed it!--number variable. We'll initialize it to 0,
and whenever an obstacle reaches the bottom of the screen, increment that number by 1. We also need to display
the score; this can be done on canvas, but my preference is to output it as HTML inside a
<div>
element, which avoids obstructing gameplay.
To do this, we'll need a new HTML element and some CSS to style it. Here's all of the HTML for the game
(with the new <div>
element added with an id
of
"score-container"
):
<div class="container">
<canvas id="paper" height=500 width=300></canvas>
<div id="score-container">score: 0</div>
</div>
Here's all of the CSS for the game as well:
body {
display: flex;
background-color: #000;
height: 95vh;
width: 95vw;
}
.container {
margin: auto;
}
#paper {
background-color: #000;
border: 1px solid #fff;
}
#score-container {
font-family: monospace;
text-align: center;
color: #fff;
}
With those in place, back in our JS code, we'll need two new variables: a number variable, score
,
which keeps track of the score, and an object variable, scoreContainer
, which will
represent the score-container
element from the HTML document. This object gives us
a function to change its HTML content. Lastly, we'll want to increment the score
variable
and display it whenever it changes, which occurs in the init()
and update()
functions. Since this code is scattered throughout the application, I'll show you the final version,
then let you find the changed lines and implement them in your version. Try searching for the new variables using
your text editor to locate them. As programs grow larger, it can make good sense to split your JS code into
multiple files!
See the Pen block dodger (part 12) by ggorlen (@ggorlen) on CodePen.
Next steps
Congratulations! You made it through quite a challenging tutorial. Please revisit it a few times, even if skimming, to take in the concepts; there is a LOT going on here. Creating your own game or application in the remaining weeks will be the best way to internalize everything you've seen. Another good idea is to find and play with very simple examples of each concept, as we did with loops.
While you're welcome to leave this game as-is, I'd also like to suggest a few areas for improvement. I implemented a couple of these features in the final game, so you can start by copying those and adjusting them to your liking. You're welcome to try any of these out or ask for help in adding them to your own game:
- Make the frequency (and/or size and/or speed) of new boxes increase as the player survives longer.
- Clear the canvas using a solid color and alpha/rgba to add visual trails to the block's movement.
- Add an animation and momentary pause when the player dies.
- Use images or sprites rather than plain squares.
- Add particle trails from the falling blocks.
- Adjust game mechanics such as the option for the player or obstacles to move in 2 dimensions rather than one.
- Add power-ups or friendly blocks.
- Implement a health meter so that being hit once or twice won't be fatal.
- Make a two-player version.
The last module discusses starting your own project!