Code this zombie-killing game and learn some functional programming in the process

Douglas Navarro
Webtips
Published in
15 min readFeb 28, 2020

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.

Famous xkcd comic on Lisp

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.

A sketched version of the top-view game that shows zombies spread around the screen and walking towards the survivor.
Zombies will creep up on you. Shoot them to stay alive.

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 setupfunction 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);
}
Screenshot of the p5js web editor showing the boilerplate code it loads up with and a blank preview window on the side.
Click the settings cog and select “High Contrast” theme if you want to be cool

Now try adding a line(0, 0, 200, 200) call right below thebackgroundcall 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.

You can use the windowHeight and windowWidth variables to make your shapes proportional to the screen

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:

  1. P5Js functions seem pretty intuitive. The docs are very good too. Keep this in your pocket.
  2. The top-left corner of the canvas is the origin of the plane, where both coordinates are zero.
  3. Shapes are drawn on the canvas on top of each other. This is why calling background after rect or line made them disappear. They are being drawn, but the background is drawn on top of it shortly after.
A screenshot of the p5Js web editor with x and y axis sketched on top.
This is how coordinates are layed out in the canvas. Top-left corner is (0,0).

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);
}
Our survivor is a bit too skinny, but that's ok

Now let us define our first zombie and draw it too.

function drawZombie(zombie) {
circle(zombie.x, zombie.y, 30);
}
Let's draw our zombie as a circle for now

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.

Short, pure functions will contain all of the logic of our game

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.

Drawing and moving just two zombies is easy

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));
If you want to transform a collection of things, map them to a new one!

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.

This is getting scary!

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:

--

--