Code this zombie-killing game and learn some functional programming in the process
Not all tutorials need to be boring
What is this all about?
Yes, there is a game involved. But this is all about functional programming. Maybe you’ve heard about it before. Maybe not. Either way, I’m not here to bother you with the dense and deep concepts that make functional code more modular, easily testable and maintainable while enabling powerful abstractions. I’m sure that what we’ll cover while coding our game will be enough to broaden your perspective.
We’ll take more of a hands-on approach and go through the necessary concepts as they are used to implement the game, and later on you can read more on functional programming if you like. Functional languages such as Lisp, Clojure or Haskell each have their own super-powers, but we’ll keep it simple and close to home with good old Javascript.
In order to make things appear on the screen, we’ll use the P5Js framework (and the p5Js web editor, if you like it), which is supported by the Processing foundation. This framework is awesome for so many reasons, but having immediate feedback off your code and the easy web editor are the most important in my opinion.
We’ll make sure our code is readable, easily extensible and as correct as possible by having clearly separated pure and impure functions. We’ll also use Javascript’s array methods whenever possible, such as map, filter and some. That will help us look at our application simply as a transformation of data through a pipeline of functions.
If you want spoilers, feel free to play it or read the final code.
The game concept
Who doesn’t enjoy shooting zombies? Let us do that, shall we?
Our project consists of a 2D, top-down view shooter game where a human survivor bravely stands alone, surrounded by zombies. Think GTA 2 but with more zombies and fewer cops.
Each zombie you shoot, you get a point. Zombie is gone. If a zombie gets to you, you’re dead. Game over. Simple enough, but thrilling, right?
To make things s̵i̵m̵p̵l̵e̵r̵ more fun, let’s say our character is wounded and that makes him unable to walk or run, he can only rotate. This guy is on the ground shooting zombies all around him and hoping for the best. I can’t wait to play this.
P5Js intro
A P5Js “sketch” program is made up of 2 functions. The setup
function and the draw
function.
Setup function runs once, draw function runs right after setup
and loops forever. Inside the draw
function is where you are supposed to place the code that will update each frame of your animation, game or whatever.
That’s enough talk, let’s get our feet wet. Go ahead and access the online editor. You’ll see this short snippet with a preview of the canvas by the right side of it:
function setup() {
createCanvas(400, 400);
}function draw() {
background(220);
}
Now try adding a line(0, 0, 200, 200)
call right below thebackground
call and hit play. Now, that's art.
Try modifying the same line of code torect(0, 0, 200, 200)
. Don’t even bother changing the parameters. Hit play again. Pretty cool, isn’t it? You can even try circle(0, 0, 200, 200)
but the reason this works is that we just got lucky. Try making this call before background
and see your shape disappear.
The documentation says that line
as well as rect
draw their shapes between two points in the screen. The first two points coordinates are the two first parameters, and the second point coordinates are given by the 3rd and 4th parameters. This leads us to a few conclusions:
- P5Js functions seem pretty intuitive. The docs are very good too. Keep this in your pocket.
- The top-left corner of the canvas is the origin of the plane, where both coordinates are zero.
- Shapes are drawn on the canvas on top of each other. This is why calling
background
afterrect
orline
made them disappear. They are being drawn, but the background is drawn on top of it shortly after.
Core abstractions
After our intro to P5Js you are probably starting to get this: drawing and animating stuff visually usually involves some geometry. It is important to think at least a little about the abstraction we’ll use to represent things in our program.
I’ve seen many times people implement a simple physics system, representing their game objects with X and Y coordinates, along with a speed or acceleration attribute that are constantly used to update that object’s position and simulate its movement.
However, if the geometry of your problem involves a lot of circular movement or things laid out across circles, it might be a good idea to use P5’s Vectors. Vectors here are not the same thing as arrays, but more like the ones used in physics.
You can instantiate vectors with the p5.Vector.fromAngle
method, all you need to do is pass in an angle (in radians) and a magnitude. I used the radians
function to convert 45 degrees into radians for me. You could go straight with PI/4
if that looks more natural to you, as p5Js already provides you with the PI constant.
Check this code for making the ball rotate around the origin. Making this with the first abstraction would require you to update X and Y in a way that is kind of tricky. This is much easier:
How would you go on about making it rotate around the center of the canvas? This is a bit tricky as the fromAngle
method does not enable you to create a vector from anywhere else except for the origin. This is about to get too mathematical, so let’s just say that you don’t need a position (xy coordinates) to represent a vector and p5Js knows that.
We can translate
our objects in the canvas by half of the canvas' width horizontally and half of the canvas’ height vertically. This way, (0,0) is now drawn at (200, 200), although we haven’t touched the vector itself.
Notice, that the translate
function is just one big side-effect. It affects everything being drawn inside the draw
function even though it was called only once. The docs make this clear by stating that
Transformations are cumulative and apply to everything that happens after and subsequent calls to the function accumulates the effect.
There is a way of working around this, which is also brought up in the docs. That is by calling the push
and pop
functions to prevent the effects of functions such as translate
from splashing onto other functions that draw on the canvas. No need to get into too much detail now, as we’ll get back to this later.
Static survivor and moving zombies
Our game has an undeniable radial nature: zombies are placed in circles around the survivor. Also, the survivor is placed at the center of all these circles and does not move, only rotates.
Because of this, it makes more sense to model our game objects as (guess what!) vectors. As we’ve seen earlier, vectors are easy to rotate. They are also not hard to stretch or compress, simply by changing their magnitude.
The down side is that they always originate from the (0,0) coordinate, but we managed to draw them on the center of the screen by using the translate
function. This compromise is acceptable because, as you’ll see later, we’ll keep this workaround only in impure functions and our abstractions will remain untouched.
Okay, let’s define our survivor and define our first function to draw him.
function drawSurvivor (survivor) {
line(0, 0, survivor.x, survivor.y);
}
Now let us define our first zombie and draw it too.
function drawZombie(zombie) {
circle(zombie.x, zombie.y, 30);
}
Now let's make our zombie move! This can be achieved by decreasing the magnitude of its vector and drawing it again. Running this on every draw loop makes it look like it’s moving.
Remember, the (x, y) coordinates are just the tip of our vector. If we make it grow shorter, it will eventually be so short that it is just a point on (0,0), the “center” of our screen, which is also where our survivor stands.
So let's define this function that receives a zombie and returns a new zombie, only with it's magnitude a bit smaller:
function moveZombie(zombie) {
return p5.Vector.fromAngle(zombie.heading(), zombie.mag() — 1);
}
Now all you need to do is call it on every iteration of the draw
loop to have a new, "moved" zombie to draw.
I've also refactored the creation of a zombie and a survivor into their own functions as we want to create more zombies later on.
Now what I want to do is enable multiple zombies being created, moved and drawn. But that’s a lot of stuff to do at once. So let’s only do the minimum necessary to have two zombies being created and drawn.
All I did was change the gameZombie
variable to be a gameZombies
array and initialised its value with two zombies in the setup
function. I am also moving them by instantiating a new array explicitly.
I used javascript’s Array.prototype.forEach
method to run the drawZombie
function for each zombie in the gameZombies
array.
function drawZombies(zombies) {
zombies.forEach(zombie => drawZombie(zombie));
}
Ok, we did it! But how would you make an arbitrary number of zombies move? You could write
movedZombies = []
for (const zombie of gameZombies) {
movedZombies.push(moveZombie(zombie));
}
gameZombies = movedZombies;
Which is not bad, but a simple map
call would make things much better:
gameZombies = gameZombies.map(moveZombie);
All
map
does is apply a function to each element of a collection, returning a new collection with the same number of elements where each element is the return value of that function.
In this case, our collection is the gameZombies
array and our function is moveZombie
. If you want it to be more explicit, you can define an anonymous function that simply shows that, for every zombie
element, the function moveZombie
will be applied.
gameZombies = gameZombies.map(zombie => moveZombie(zombie));
Now let's create the zombies in random directions and have some logic to create a new zombie in a random distance if there aren't as many as we'd like i.e if the count of zombies is below some threshold. For that we'll change the createZombie
function a little bit using the random
function, and define the spawnNewZombie
function.
function createZombie(distance = 75) {
return p5.Vector.fromAngle(radians(random(0, 360)), distance);
}function spawnNewZombie(zombies, threshold) {
if (zombies.length < threshold) {
zombies.push(createZombie(distance = random(0.2 * windowHeight, 0.8 * windowHeight)));
}
return zombies;
}
Notice that in the draw
loop, we're assigning gameZombies
again and again with the output of each function, effectively making the gameZombies
array go "through" each one of them.
Pure vs Impure functions
I think this is a good moment for us to think about our code a little. You may have seen the comments I've left separating //Pure functions
from //Side-effects
but do you know what those mean?
In computer programming, a pure function is a function that has the following properties:
1. Its return value is the same for the same arguments (no variation with local static variables, non-local variables, mutable reference arguments or input streams from I/O devices).
2. Its evaluation has no side effects (no mutation of local static variables, non-local variables, mutable reference arguments or I/O streams).
Making this separation clear has a number of advantages. Whenever a function is pure it automatically has some superpowers. These are some of them:
- Easier to reason about, as they tend to be short and concise
- Easier to unit test and debug, as little to no mocking is required
- Referentially transparent, meaning they will always have the same output given the same input and will produce no side-effect
- Memoizable, meaning that you can store its results in memory and safely use them whenever the input is repeated. This is safe because the function is referentially transparent!
In our game, side-effects are mostly related to drawing on the canvas or reading user input.
In a broader sense, however, any sort of IO can be considered a side-effect (reading from network or disk etc.), and even throwing exceptions can be considered a side-effect, although that's very debatable.
Our pure functions, however, will contain all of the game logic. This enables great decoupling and makes it much easier to implement new features.
If I want zombies to do something different, all I have to do is implement a new pure function and pipe my zombies through it. Same thing applies to survivor or any other game objects.
In a more professional scenario, we would be able to test all of the game logic separately from the rendering, which shows a good level of decoupling between the concepts that make up the game from the way that is presented. If we wanted to take this decoupling really seriously, we would only need to remove the ambiguity that exists between our game objects and p5Js' vectors. After that, we could use whatever framework we wanted and represent our game objects simply with properties such as magnitude and direction.
The survivor is alive
If our survivor is to stand any chance, he needs to rotate and shoot. Let’s do rotation first. The pure function to do this is pretty straight-forward: it receives a vector and needs to return a vector that has the same magnitude, but with a slightly different direction. Let's also make it receive a clockwise
or counterclockwise
parameter so it knows to which direction the survivor needs to moved.
function moveSurvivor(survivor, direction) {
let angleDiff = 0;
if (direction == "counterclockwise") {
angleDiff = radians(-5);
} else if (direction == "clockwise") {
angleDiff = radians(5);
}
return p5.Vector.fromAngle(survivor.heading() + angleDiff,
survivor.mag());
}
Now in the impure side of this which is getting user input, p5Js enables you to call the function keyIsDown
with a key as parameter, returning true
if that key parameter is actually down. This enables us to poll for user input and call moveSurvivor
with the right direction.
if (keyIsDown(RIGHT_ARROW)) {
gameSurvivor = moveSurvivor(gameSurvivor, “clockwise”);
} else if (keyIsDown(LEFT_ARROW)) {
gameSurvivor = moveSurvivor(gameSurvivor, “counterclockwise”);
}
Now let's make him shoot. Each shot will actually be a new game object, so think about how it can be represented using our core abstraction (everything is a vector).
It must come out of our survivor's gun, that is, from the center of the screen, and propagate towards the edges of the screen, always with the same direction. We can basically write the same functions used to create and a move a zombie, except that for a shot, "moving" means increasing the magnitude of the vector that represents it!
function createShot(angle) {
return p5.Vector.fromAngle(radians(angle), 1);
}function moveShot(shot) {
return p5.Vector.fromAngle(shot.heading(), shot.mag() + 3);
}function moveShots(shots) {
return shots.map(moveShot);
}
Drawing shots is basically the same as drawing zombies, let’s just use a smaller diameter for the circle so it doesn't confusing.
function drawShots(shots) {
shots.forEach(shot => drawShot(shot));
}function drawShot(shot) {
circle(shot.x, shot.y, 5);
}
Let's also create a global gameShots
variable and initialize it as an empty array. Apart from polling with the keyIsDown
function, P5Js offers a cleaner way of getting user input which is through defining our own keyPressed
function that automagically runs whenever a key is pressed and sets the value of keyCode to identify which one. Let's use this to check that the player has pressed the spacebar and push a new shot into the gameShots array.
function keyPressed() {
if (keyCode == 32) {
newShot = createShot(gameSurvivor.heading());
gameShots.push(newShot);
}
}
Something else we need to take into consideration is that although we stop seeing the shots after their coordinates leave the canvas, they are still in the array. That’s wasted memory and we should remove those, so this is a great opportunity for us to use the filter method. According to the MDN web docs,
The
filter()
method creates a new array with all elements that pass the test implemented by the provided function.
In other words, filter
will create an array of the same size or smaller, only leaving the elements that make the provided function return true
and removing those for which the function returns false
.
If we want to remove all the shots that are too far away from the survivor, we can simply filter them out by checking if their magnitude is too high. For very simple checks like this, the anonymous function defined by the =>
operator is a good choice:
function cleanShots(shots) {
return shots.filter(shot => shot.mag() < windowWidth / 2);
}
Collision
What logic could figure out whether a bullet has met the rotten face of a zombie? Given our core abstraction, we could approach this by checking that both the angle and the magnitude of the vectors that represent the zombie and the shot are the same.
That kind of works, but there is a certain disconnection between checking the properties of the vectors and the visual representation of the game object (circles, in this case). That would work well if we wanted collision to happen only if the center of the shot and the center of the zombie met, but we actually want to check if any pixel of the shot is inside the zombie. Thus, we have to take their diameters into consideration:
function collision(zombie, shot) {
let xInsideZombie = abs(zombie.x - shot.x) < 30 / 2;
let yInsideZombie = abs(zombie.y - shot.y) < 30 / 2;
return xInsideZombie && yInsideZombie;
}
See that 30/2
? That's the radius of our zombies because we are drawing them as circles of diameter 30, but that's bad code. We actually need to go back and refactor the model for the zombie to have the information for its diameter or radius. I could leave that hard-coded 30/2 but I’m absolutely certain it would become a show stopper really soon. Also, this works well enough because the diameter of our shot is pretty small. Otherwise, we would need to take it into consideration as well.
Killing zombies
Something else we need to do is have a way of marking a zombie as dead or alive so we can remove the dead ones. Thus, let’s now say a zombie is a javascript object with the vector
, diameter
and killed
keys. We have to make every function that returns a zombie return not just the vector, but a javascript object with those keys. Also, the drawZombie
function will have to get a zombie’s (x,y) coordinates from the vector in the vector
key.
function createZombie(distance = 75) {
return {
vector: p5.Vector.fromAngle(radians(random(0, 360)), distance),
diameter: 30,
killed: false
};
}
Now with the new zombie model, we can have our collision function the way it should be
function collision(zombie, shot) {
let xInsideZombie = abs(zombie.vector.x - shot.x) < zombie.diameter / 2;
let yInsideZombie = abs(zombie.vector.y - shot.y) < zombie.diameter / 2;
return xInsideZombie && yInsideZombie;
}
Ok, now how will we check if any shot has collided with any zombie? A nested for
loop comes to mind, doesn’t it? Yikes. But it doesn’t have to be that hard.
Let’s check if one zombie is hit by some
shot:
function zombieKilled(zombie, shots) {
return {
vector: zombie.vector,
diameter: zombie.diameter,
killed: shots.some(shot => collision(zombie, shot))
};
}
The
some()
method tests whether at least one element in the array passes the test implemented by the provided function. It returns a Boolean value.
And pipe our zombies through a new transformation, the one that will mark them as killed if a shot has collided with them
function zombiesKilled(zombies, shots) {
return zombies.map(zombie => zombieKilled(zombie, shots));
}
Notice, here, that the nested for
loop we first thought of, still exists. You can think of the inner loop as the some
function. It will iterate over the shots and check if any of them has hit this one zombie. The map
function works like the outerfor
loop that would iterate over the zombies.
So, there is not a lot of magic here, but the path between reading the code and understanding what it does is much shorter.
Also, the way we wrote and thought of the code made it much more natural to write it with a good amount of decoupling. I, myself, would usually write that nested for loop only to later realize it was not a good idea.
Thinking in terms of map, some and filter forces you to write small, concise functions that are used to transform collections.
Finally, let’s filter
out the zombies that have been killed
function cleanKilledZombies(zombies) {
return zombies.filter(zombie => !zombie.killed);
}
Wrap up and challenge
Given that my goal was to present some of the benefits of functional programming with real code, I think I’ve reached it by now. We’ve seen how map
, filter
and some
made us think and write the code differently than what we’re used to.
There are, however, a few things left to be implemented. You may challenge yourself by sticking with JS’ Array.prototype
methods when coding them. Some of them are:
- Count how many zombies have been killed, that would be the game score.
- Check if
some
zombie has found the survivor. That would set the game over state. - Remove the shot if it has already hit a zombie. You could refactor our model for shots just like we did with the zombies.
There are also some things more to learn on P5Js, such as using images instead of simple shapes and more advanced usage of the translate
or rotate
functions. I really enjoyed using P5Js for this and it is without any doubt an amazing tool.
Feel free to see what I’ve come up with for a slightly more polished version of the game some time before writing this.
Resources:
- P5Js.org
- editor.p5js.org
- https://en.wikipedia.org/wiki/Pure_function
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/some
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter