How To Code a Top-Down Shooter 🔫

Growing up I absolutely loved arcade top-down shooters, I’d spend hours running and gunning trying to best my previous score. In particular, I’d play a lot of zombie shooters, with the goal of simply surviving for as long as possible.

In this blog, we’re going to create our own top-down-run-and-gun-zombie-shooter. So buckle up and get your pistols ready.

The goal

By the end of this blog, we should have a working game that’ll give us the foundation to extend and create something special! The main goal here is to outline the fundamental game-development concepts for creating a top-down shooter. This blog is suitable for all levels of programming and is an appropriate follow on from the Top 5 BEST games to code as a beginner, but if you haven’t read it, no worries, you should be fine. We’ll be using p5.js to code the game for ease of interaction with the HTML canvas – to jump right in, I’d suggest opening up the online p5.js editor.

The main protagonist

The first thing we’re going to do is create our hero and give them the ability to walk.

Create a new file, call it Player.js

Then we’ll need to reference that file in our index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.4.0/p5.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.4.0/addons/p5.sound.min.js"></script>
    <link rel="stylesheet" type="text/css" href="style.css">
    <meta charset="utf-8" />

  </head>
  <body>
    <script src="sketch.js"></script>
    <script src="Player.js"></script> <!-- Add this -->
  </body>
</html>

We’ll create a Player class that’ll have one field pos to represent the position of the player on the screen (pos is a vector that contains the x and the y location). Also, we’ll add a draw() function that’ll just contain a rect (rectangle) for the moment to be our player.

class Player {
  constructor() {
    this.pos = createVector(width / 2, height / 2)
  }
  
  draw() {
    rect(this.pos.x, this.pos.y, 20, 20);
  }
}

Now we need to update the sketch.js to create the player object and draw them:

let player;
function setup() {
  createCanvas(700, 700);
  player = new Player();
}

function draw() {
  background(100, 100, 100);
  rectMode(CENTER);
  player.draw();
}

Which, when played should give us:

Zombie destroy an image of a white square

There we have our zombie destroyer.

Player movement

In most top-down shooters I’ve played you’d use WASD to control the player’s up/down, left/right movement and then the mouse to determine where the player’s facing. You could also move at the same speed in all directions, so let’s do that.

Let’s add an update method to our Player class that’ll update the pos vector depending on which keys are currently held down.

class Player {
  constructor() {
    this.pos = createVector(width / 2, height / 2)
  }
  
  draw() {
    rect(this.pos.x, this.pos.y, 20, 20);
  }
  
  update() { // add this
    let xSpeed = 0;
    let ySpeed = 0;
    if (keyIsDown(65)) {
      xSpeed = -2;
    }

    if (keyIsDown(68)) {
      xSpeed = 2;
    }

    if (keyIsDown(87)) {
      ySpeed = -2;
    }

    if (keyIsDown(83)) {
      ySpeed = 2;
    }
    this.pos.add(xSpeed, ySpeed);
  }
}

And update sketch.js to call the update method on the player object.

let player;
function setup() {
  createCanvas(700, 700);
  player = new Player();
}

function draw() {
  background(100, 100, 100);
  rectMode(CENTER);
  player.draw();
  player.update(); // add this
}

Hit play, and you should have something like this:

A gif of a white square moving about

Rotating the player to the mouse position

Given the position of the mouse we need to determine the angle from the player’s position to that specified point and then rotate the player so that they’re facing the cursor. To do this we can use the atan2 function which makes our life a bit easier.

So the two functions that are changing here are within the Player class, the draw() and the update(). Here’s the code, I’ll explain in more detail underneath.

class Player {
  constructor() {
    this.pos = createVector(width / 2, height / 2)
    this.angle = 0;
  }
  
  draw() { // update draw
    push();
    translate(this.pos.x, this.pos.y);
    rotate(this.angle);
    rect(0, 0, 20, 20);
    pop();
  }
  
  update() {
    let xSpeed = 0;
    let ySpeed = 0;
    if (keyIsDown(65)) {
      xSpeed = -2;
    }

    if (keyIsDown(68)) {
      xSpeed = 2;
    }

    if (keyIsDown(87)) {
      ySpeed = -2;
    }

    if (keyIsDown(83)) {
      ySpeed = 2;
    }
    this.pos.add(xSpeed, ySpeed);
    this.angle = atan2(mouseY - this.pos.y, mouseX - this.pos.x); // add this
  }
}

So each frame in our update() function we’re setting the angle using the atan2 we spoke about above, mouseY and mouseX are two handy properties p5 provides us with to track the position of the cursor.

In the draw() function you’ll notice we’ve added a few things here:

  • push() and pop() functions are always used together, it allows us to add transformation settings that doesn’t cascade to other objects.
  • translate() allows us to displace objects within the window. So we’re translating to the position of the player to make it our point of origin. This allows us to rotate from that point. Notice that because we’ve translated to the position of the player, we set the x and y in the rect(x, y.. to 0 (Because we’ve translated to that position).
  • rotate() does exactly what you’d expect, rotates.

You should now have something like this:

A gif of a white square moving about facing the mouse

Adding zombies

Our game is feeling a little bit empty, let’s add a horde of zombies.

Taking similar steps as we did before, let’s create a new file Zombie.js (Remember to add the reference in the index.html as we did earlier)

The blue areas in the image below are our zombie spawning zones. They can either come from the top or from the bottom.

An image displaying different coordinates on the screen

We’re also going to provide the zombie with a speed argument that’ll be used later on to make the game more difficult.

Here’s the code for the Zombie class, I’ll explain in more detail underneath:

class Zombie {
  
  constructor(speed) {
    this.speed = speed;
    let y;
    if (random(1) < 0.5) {
      // from the top
      y = random(-300, 0);            
    } else {
      // from the bottom
      y = random(height, height + 300);
    }
    
    let x = random(-300, width + 300);
    this.pos = createVector(x, y);
  } 
  
    
  draw() {
    push();
    fill(100, 255, 100);
    rect(this.pos.x, this.pos.y, 20, 20);
    pop();
  }
  
  
  update() {
    let difference = p5.Vector.sub(player.pos, this.pos);
    difference.limit(this.speed);
    this.pos.add(difference);
  }
}

When a zombie object is created we pass in a speed (To begin with we’ll just hardcode it to some value). random(1) < 1 creates a random number between 0 and 1 and if it’s less than 0.5 we spawn the zombie from the top else we’ll spawn it from the bottom.

The draw() function just creates a green rectangle, notice how we’re using push() and pop() again, so that the colour green doesn’t cascade – try it out for yourself, remove the push() and pop() to see what happens.

The update() function subtracts the vector for the position of the player and the position of the zombie, the difference is added to the zombie position, we limit this by the zombie speed. This allows the zombie to move towards the player.

Next, let’s open up sketch.js and create the array of zombies:

let player;
let zombies = []; // add this
function setup() {
  createCanvas(700, 700);
  player = new Player();
}

function draw() {
  background(100, 100, 100);
  rectMode(CENTER);
  player.draw();
  player.update(); 
  
  for (let zombie of zombies) { // add this
    zombie.draw();
    zombie.update();
  }
  
  if (frameCount % 200 == 0) {   // add this
    zombies.push(new Zombie(2));
  }
}

Notice how we’re creating a zombie object every 200 frames and setting the speed to 2, these will both change later.

You should now have something that resembles the following:

Zombies chasing our protaganist

Rotating the zombies towards us

At the moment the zombies aren’t facing us, they’re just rigid rectangles that don’t fancy rotating. Let’s add some rotation so it really looks as though they’re focusing on us.

This is similar code to before where we rotated the player’s position to the mouse cursor. Update the draw() function in the Zombie class to be the following:

  draw() {
    push();
    fill(100, 255, 100);
    let angle = atan2(player.pos.y - this.pos.y, player.pos.x - this.pos.x);
    translate(this.pos.x, this.pos.y);
    rotate(angle);
    rect(0, 0, 20, 20);
    pop();
  }

Now we should have the zombie’s undivided attention:

zombies chasing our protagonist and facing him

Adding the ability to shoot the zombies!

We’ve got zombies, we’ve got their attention, we’ve not got a gun. Let’s fix that.

Let’s create a new Bullet.js file (Make sure to add it to the index.html as done previously)

class Bullet {
  constructor(x, y, angle) {
    this.x = x;
    this.y = y;
    this.angle = angle;
    this.speed = 16;
  }
  
  
  draw() {
    push();
    fill(0);
    circle(this.x, this.y, 5);
    pop();
  }
  
  update() {
    this.x += this.speed * cos(this.angle);
    this.y += this.speed * sin(this.angle);
  }
}

We provide the bullet class with an x and a y which represents the player position at the point of shooting the bullet. The angle of the player is also passed in. The update() function moves the bullet along the trajectory.

Let’s create an array of bullet objects in the Player class and have a function called shoot() which’ll create the bullets:

class Player {
  constructor() {
    this.pos = createVector(width / 2, height / 2)
    this.angle = 0;
    this.bullets = []; // add this
  }
  
  draw() {
    push();
    translate(this.pos.x, this.pos.y);
    rotate(this.angle);
    rect(0, 0, 20, 20);
    pop();
    
    for (let bullet of this.bullets) {  // add this
      bullet.update();
      bullet.draw();
    }
  }
  
  update() {
    let xSpeed = 0;
    let ySpeed = 0;
    if (keyIsDown(65)) {
      xSpeed = -2;
    }

    if (keyIsDown(68)) {
      xSpeed = 2;
    }

    if (keyIsDown(87)) {
      ySpeed = -2;
    }

    if (keyIsDown(83)) {
      ySpeed = 2;
    }
    this.pos.add(xSpeed, ySpeed);
    this.angle = atan2(mouseY - this.pos.y, mouseX - this.pos.x); // add this
  }
  
  shoot() { // add this
    this.bullets.push(new Bullet(this.pos.x, this.pos.y, this.angle));
  }
}

Notice how we are updating and drawing the bullets all within the draw() function. Usually you’d separate this so that you’d only do updating in the update() function and drawing in the draw() function, but for simplicity I’ve added both here.

Next, in sketch.js we need to call the shoot() function when the player clicks the mouse button:

function mouseClicked() {
  player.shoot();
}

Bullet collision

Zombies chasing our protagonist and the protagonist shooting them but the bullets are going through them

Bullet collision

It’s all well and good being able to shoot, but if the bullets ain’t gonna hit, what’s the point?

Let’s add some code to detect if a bullet has hit a zombie and if it has we remove the zombie from the array.

We can determine if a bullet has hit a zombie looking at the following image. Given half the width of the player and the radius of the bullet. If the distance between the two objects is less than or equal to the sum of those values. We have a hit.

An image of a square next to a circle, it displays the width of the square and the radius of the circle. It shows that sum of the radius and half the width of the rectangle together should be as close together as they can get before they intersect - meaning the zombie has eaten us

In the player class let’s add a new method hasShot(zombie):

  hasShot(zombie) {
    for (let i = 0; i < this.bullets.length; i++) {
      if (dist(this.bullets[i].x, this.bullets[i].y, zombie.pos.x, zombie.pos.y) < 15) {
        this.bullets.splice(i, 1);
        return true;
      }
    }
    return false;
  }

This method returns true if we’ve shot the passed in zombie and returns false if we haven’t. If we have shot them, we remove the bullet from the bullets array.

I’ve just hardcoded the summed radius here to 15. Ideally, you’d the radius of the player and the radius of the bullet as variables.

Next, we need to update the sketch.js so that we remove the zombie if we hit them:

let player;
let zombies = [];
function setup() {
  createCanvas(700, 700);
  player = new Player();
}

function draw() {
  background(100, 100, 100);
  rectMode(CENTER);
  player.draw();
  player.update(); 
  
  // add this
  for (let i = zombies.length - 1; i >= 0; i--) {
    zombies[i].draw();
    zombies[i].update();
    
    if (player.hasShot(zombies[i])) {
      zombies.splice(i, 1);
    }
  }
  
  if (frameCount % 200 == 0) {
    zombies.push(new Zombie(2));
  }
}

function mouseClicked() {
  player.shoot();
}

Notice how we are looping through the zombies backwards, this is good practice when removing items from an array so we don’t mess up the indexes.

A gif of the protagonist shooting the zombies

Making the game progressively more difficult

At the moment the game is too easy. The longer we’re alive the more difficult the game should get. We’re going to increase the difficulty in two ways:

  • Increase the number of zombies
  • Increase the speed of the zombies

Increasing zombie count

Let’s create two new variables in sketch.js, zombieSpawnTime and frame.

We will increment the frame each time draw() is executed and spawn a zombie if frame >= zombieSpawnTime

Then we will decrease the zombieSpawnTime so that zombies are more frequent. sketch.js should now look like:

let player;
let zombies = [];
// add these
let zombieSpawnTime = 300;
let frame = 0
function setup() {
  createCanvas(700, 700);
  player = new Player();
}

function draw() {
  background(100, 100, 100);
  rectMode(CENTER);
  player.draw();
  player.update(); 
  
  
  for (let i = zombies.length - 1; i >= 0; i--) {
    zombies[i].draw();
    zombies[i].update();
    
    if (player.hasShot(zombies[i])) {
      zombies.splice(i, 1);
    }
  }
  
  // add this
  if (frame >= zombieSpawnTime) {
    zombies.push(new Zombie(2));
    zombieSpawnTime *= 0.95;
    frame = 0;
  }
  frame++;
}

function mouseClicked() {
  player.shoot();
}

After 20 seconds

Zombies coming in dribs and drabs

After 1 minute

More zombies coming in

After 2 minutes 😱

Hundreds and hundreds of zombies attacking

You’ll likely want to add a cap on the number of zombies that spawn 😂

Increasing zombie speed

As the game goes on the zombies should increase their speed. A fun way to do this would be to generate a random speed for a zombie. But, increase the maximum potential speed the longer you survive.

For this, we need one new variable in sketch.js, zombieMaxSpeed:

let player;
let zombies = [];

let zombieSpawnTime = 300;
let zombieMaxSpeed = 2;  // add this
let frame = 0
function setup() {
  createCanvas(700, 700);
  player = new Player();
}

function draw() {
  background(100, 100, 100);
  rectMode(CENTER);
  player.draw();
  player.update(); 
  
  
  for (let i = zombies.length - 1; i >= 0; i--) {
    zombies[i].draw();
    zombies[i].update();
    
    if (player.hasShot(zombies[i])) {
      zombies.splice(i, 1);
    }
  }
  
  if (frame >= zombieSpawnTime) {
    zombies.push(new Zombie(random(zombieMaxSpeed)));
    zombieSpawnTime *= 0.95;
    frame = 0;
  }
  if (frameCount % 1000 == 0) { // add this
    zombieMaxSpeed += 0.1;
  }
  
  frame++;
}

function mouseClicked() {
  player.shoot();
}

So every 1000 frames, we increase the maximum speed of a zombie.

Zombies moving fast and slow, and a lot of them

Good luck.

Adding a point system

The first thing we need to do is make it possible for zombies to eat you.

Add the following ateYou() method to the Zombie class:

  ateYou() {
    return dist(this.pos.x, this.pos.y, player.pos.x, player.pos.y) < 20;
  }

This will return true if the zombie touches you.

Now let’s restart the game if the zombie eats you, your sketch.js should look like the following:

let player;
let zombies = [];

let zombieSpawnTime = 300;
let zombieMaxSpeed = 2;
let frame = 0
function setup() {
  createCanvas(700, 700);
  player = new Player();
}

function draw() {
  background(100, 100, 100);
  rectMode(CENTER);
  player.draw();
  player.update(); 
  
  
  for (let i = zombies.length - 1; i >= 0; i--) {
    zombies[i].draw();
    zombies[i].update();
    
    if (zombies[i].ateYou()) { // add this
      restart();
      break;
    }
    
    if (player.hasShot(zombies[i])) {
      zombies.splice(i, 1);
    }
  }
  
  if (frame >= zombieSpawnTime) {
    zombies.push(new Zombie(random(zombieMaxSpeed)));
    zombieSpawnTime *= 0.95;
    frame = 0;
  }
  if (frameCount % 1000 == 0) {
    zombieMaxSpeed += 0.1;
  }
  
  frame++;
}

// add this
function restart() {
  player = new Player();
  zombies = [];
  zombieSpawnTime = 300;
  zombieMaxSpeed = 2;
}

function mouseClicked() {
  player.shoot();
}

And your game:

Zombies eating the player and the game resetting

Adding a score

Adding a score is really simple. We’ve already got code that tells us when we’ve shot a zombie, let’s just increment a score when that happens.

let player;
let zombies = [];

let zombieSpawnTime = 300;
let zombieMaxSpeed = 2;
let frame = 0
let score = 0; // add this
function setup() {
  createCanvas(700, 700);
  player = new Player();
}

function draw() {
  background(100, 100, 100);
  rectMode(CENTER);
  player.draw();
  player.update(); 
  
  
  for (let i = zombies.length - 1; i >= 0; i--) {
    zombies[i].draw();
    zombies[i].update();
    
    if (zombies[i].ateYou()) {
      restart();
      break;
    }
    
    if (player.hasShot(zombies[i])) {
      score++; // add this
      zombies.splice(i, 1);
    }
  }
  
  if (frame >= zombieSpawnTime) {
    zombies.push(new Zombie(random(zombieMaxSpeed)));
    zombieSpawnTime *= 0.95;
    frame = 0;
  }
  if (frameCount % 1000 == 0) {
    zombieMaxSpeed += 0.1;
  }
  
  frame++;
  // add these
  textAlign(CENTER);
  textSize(40);
  text(score, width/2, 100);
}

function restart() {
  player = new Player();
  zombies = [];
  zombieSpawnTime = 300;
  zombieMaxSpeed = 2;
  score = 0; // don't forget to reset the score :D
}

function mouseClicked() {
  player.shoot();
}

Notice the use of the text functions, these are p5.js functions and make it really easy to display text in the format we want on the canvas.

Score incrementing when shooting a zombie

What next?

There are so many things we can add to this now we’ve got down the basic mechanics.

  • Add new weapons
  • Add zombies that are harder to kill
  • Weapon upgrades at the expense of points
  • Increase player speed at the expense of points
  • Add images to represent the characters (Below is my attempt)
A slightly better version of the game with actual sprites for the player/zombies.

Here’s a link to the code shown in this blog. And here’s a link to the extra bits I’ve done above (The code in this one may be a little different to the original).

Leave a Reply