DEV Community

Cover image for Zombie Shooter
Nathan Pham
Nathan Pham

Posted on • Updated on

Zombie Shooter

Hi! In this post I'll be showing you how to create a simple 2D zombie shooting game using vanilla JS and the HTML5 canvas. All of the code can be found on my github.

Live Demo

This project is hosted live on repl.it, so go check out what we'll be making here.

Folder Structure

It's often pretty confusing to deal with lengthy coding tutorials like these, so I've provided a simple folder structure that might help. I know my file naming isn't the best (ie: not capitalizing class file names), but you can change those as needed.

index.html
css /
    globals.css
    index.css
js /
    index.js
    config.js
    classes /
        bullet.js
        player.js
        zombie.js
    libs /
        animate.js
        input.js
        pointer.js
        utils.js
Enter fullscreen mode Exit fullscreen mode

Code Snippets

In a lot of code tutorials, I've seen people put ... indicating where previously written blocks of code were. In this project, I didn't add or shorten code blocks using ellipses. Everything I wrote will be added to the previous snippet of code, so don't delete anything even if you don't see it in the current code snippet.

Remember, if this gets to confusing or you want to see where functions should be placed, check out the code on github.

HTML Layout

Let's start by making our HTML skeleton. All this really needs to have is a canvas, minimal styles, and our script. I won't be using Webpack in this project, so let's take advantage of browser modules instead.

index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width" />
  <title>Shooter</title>
  <link href="/css/globals.css" rel="stylesheet" />
  <link href="/css/index.css" rel="stylesheet" />
  <script src="/js/index.js" type="module"></script>
</head>
<body>
  <div id="app">
    <canvas id="app-scene"></canvas>
  </div>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

So far, we've added basic meta tags, a canvas, and included our CSS and JS files.

Basic CSS

You can skip this part on CSS. I just included it in case I expand the project, like adding a start menu. Generally in my projects, css/globals.css contains box-sizing resets and any variables for the theme of the site. css/index.css has everything else needed to style index.html. Again, this step is mostly unnecessary considering most of the work will be done in JS.

css/globals.css

html, body {
  height: 100%;
  width: 100%;
  padding: 0;
  margin: 0;
  box-sizing: border-box;
  overflow: hidden; /* generally you don't mess with this but I don't want any scrolling regardless */
}

*, ::before, ::after {
  box-sizing: inherit;
}
Enter fullscreen mode Exit fullscreen mode

css/index.css

/* make the canvas wrapper expand to the entire page */
#app {
  min-height: 100vh;
  width: 100%;
}

/* make canvas expand to the entire page */
#app-scene {
  height: 100%;
  width: 100%;
}
Enter fullscreen mode Exit fullscreen mode

JavaScript

This part is a bit more difficult, so I've broken it up into several sections. If you're stuck, you can always compare your work to the solution code.

Config

Normally, you'd want to put variables that alter the behavior of the game in config.js. For example, you could specify the player's speed, or how many hitpoints a zombie should have. I'll leave the specifics to you, so all I'm exporting is how big the canvas should be (the entire screen).

js/config.js

const width = window.innerWidth
const height = window.innerHeight

export {
  width,
  height
}
Enter fullscreen mode Exit fullscreen mode

Utils

Libraries like p5.js provide a host of built-in functions that simplify down the math. The only functions we'll need are an implementation of random and distance.

js/libs/utils.js

const random = (min, max) => {
  return (Math.random() * (max - min)) + min
}

const distance = (x1, y1, x2, y2) => {
  let xx = Math.pow((x2 - x1), 2)
  let yy = Math.pow((y2 - y1), 2)
  return Math.sqrt(xx + yy)
}

export {
  random,
  distance
}
Enter fullscreen mode Exit fullscreen mode

Animating

First, we need to reference our canvas and set up a basic game loop. The main rendering & update process will be set up in js/libs/animate.js, and then imported to use in js/index.js.

We'll be using window.requestAnimationFrame to drive the game loop. I've pretty much ripped this off of Stack Overflow, but I'll do my best to explain what's happening.

Here, we're initializing all of the variables we'll be using. update is a function we'll pass into the animate function (see below) that we want to run every frame.

js/libs/animate.js

let interval, start, now, then, elapsed
let update
Enter fullscreen mode Exit fullscreen mode

startAnimation sets our animation to 60 fps and starts the animationLoop function, which recursively calls with requestAnimationFrame.

js/libs/animate.js

const startAnimation = () => {
  interval = 1000 / 60
  then = Date.now()
  start = then
  animationLoop()
}

// recursively call animationLoop with requestAnimationFrame
const animationLoop = () => {
  requestAnimationFrame(animationLoop)

  now = Date.now()
  elapsed = now - then

  if(elapsed > interval) {
    then = now - (elapsed % interval)
    update()
  }
}
Enter fullscreen mode Exit fullscreen mode

Finally, we export a utility function to set update and start the animation.

js/libs/animate.js

const animate = (u) => {
  update = u
  startAnimation()
}

export default animate
Enter fullscreen mode Exit fullscreen mode

Here, we resize the canvas and retrieve the canvas context, allowing us to draw items on the screen. Then we animate a blank update function, which we'll be filling in very soon.

js/index.js

import animate from "./libs/animate.js"
import { width, height } from "./config.js"

// get the canvas and context
const canvas = document.getElementById("app-scene")
const ctx = canvas.getContext("2d")

Object.assign(canvas, {
  width, height
})

const update = () => {
  ctx.clearRect(0, 0, width, height) // refreshes the background
}

animate(update)
Enter fullscreen mode Exit fullscreen mode

A Player

If you throw a console.log into update, you'll see it being repeatedly run but nothing is drawn onto the screen. It's time to add a player that we can control!

For now, I'm initializing the class with some default variables and blank functions.

js/classes/player.js

import { width, height } from "../config.js"

class Player {
  vector = {
    x: width / 2,
    y: height / 2
  }
  speed = 2
  radius = 20
  angle = - Math.PI / 2

  rotate() {}
  move() {}
  update() {
    this.move()
  }
  render(ctx) {}
}

export default Player
Enter fullscreen mode Exit fullscreen mode

Rendering the Player

In Player.render we'll specify how the character in our game should look. I'm not using a spritesheet and I'm not a pro in designing assets, so our player will literally be a skin-colored ball.

The seemingly random -2 or +5 is used to adjust the location of the arms and gun, so play around with the coordinates I'm passing into the drawing functions. A lot of what I've done to make the player look decent is guess and check.

js/classes/player.js

render(ctx) {
  // rotation logic (doesn't do anything for now)
  ctx.save()

  let tX = this.vector.x 
  let tY = this.vector.y 
  ctx.translate(tX, tY)
  ctx.rotate(this.angle)
  ctx.translate(-tX, -tY)

  // Draw a circle as the body
  ctx.beginPath()
  ctx.fillStyle = "#ffe0bd"
  ctx.arc(this.vector.x, this.vector.y, this.radius, 0, Math.PI * 2)
  ctx.fill()

  // Draw a black rectangle as the "gun"    
  ctx.beginPath()
  ctx.fillStyle = "#000"
  ctx.rect(this.vector.x + this.radius + 15, this.vector.y - 5, 25, 10)
  ctx.fill()

  // Specify how the hands should look
  ctx.beginPath()
  ctx.strokeStyle = "#ffe0bd"
  ctx.lineCap = "round"
  ctx.lineWidth = 4

  // Right Hand
  ctx.moveTo(this.vector.x + 5, this.vector.y + this.radius - 2) 
  ctx.lineTo(this.vector.x + this.radius + 15, this.vector.y + 5)
  ctx.stroke()

  // Left Hand
  ctx.moveTo(this.vector.x + 5, this.vector.y - this.radius + 2)
  ctx.lineTo(this.vector.x + this.radius + 15, this.vector.y - 5)
  ctx.stroke()

  // also part of the rotation logic
  ctx.restore()
}
Enter fullscreen mode Exit fullscreen mode

Onto the Screen!

After initializing the player class, we can update and render it within the animate function. Keep in mind I'm only pasting the relevant parts of code, so keep everything that we wrote before.

js/index.js

import Player from "./classes/player.js"

const player = new Player()
const update = () => {
  player.update()
  player.render(ctx)
}

animate(update)
Enter fullscreen mode Exit fullscreen mode

If all went well, you should now see a ball with a rectangle on the screen.

Movement

I experimented with the keydown event, but I noticed that I couldn't move the player in multiple directions at once. I hacked together a simple input handler that you can use to help manage this problem.

js/libs/input.js

let keymap = []

window.addEventListener("keydown", e => {
  let { key } = e
  if(!keymap.includes(key)) {
    keymap.push(key)
  }
})

window.addEventListener("keyup", e => {
  let { key } = e
  if(keymap.includes(key)) {
    keymap.splice(keymap.indexOf(key), 1)
  }
})

const key = (x) => {
  return keymap.includes(x)
}
// now, we can use key("w") to see if w is still being pressed
export default key
Enter fullscreen mode Exit fullscreen mode

Essentially, we add keys to keymap when they are pressed, and remove them when they are released. You could cover a few more edge cases by clearing the keymap when the user switches to another tab, but I was lazy.

Back in the Player class, we need to detect whenever the user presses WASD and change the position accordingly. I also made a rudimentary boundary system to prevent the player from leaving the screen.

js/classes/player.js

import key from "../libs/input.js"

class Player {
  move() {
    if(key("w") && this.vector.y - this.speed - this.radius > 0) {
      this.vector.y -= this.speed
    }
    if(key("s") && this.vector.y + this.speed + this.radius < height) {
      this.vector.y += this.speed
    }
    if(key("a") && this.vector.x - this.speed - this.radius > 0) {
      this.vector.x -= this.speed
    }
    if(key("d") && this.vector.x + this.speed + this.radius < width) {
      this.vector.x += this.speed
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Rotation

The player can move around, but the gun is only pointing upwards. To fix this, we'll need to find the location of the mouse and rotate the player towards it.

Technically we don't need to get the canvas's position because it covers the entire screen. However, doing so allows us to use the same function even if we change the canvas's location.

js/libs/pointer.js

const pointer = (canvas, event) => {
  const rect = canvas.getBoundingClientRect()
  const x = event.clientX - rect.left
  const y = event.clientY - rect.top

  return {
    x, y
  }
}

export default pointer
Enter fullscreen mode Exit fullscreen mode

The player needs to rotate towards the pointer coordinates, so let's quickly add that in. We already added logic to account for the player's angle, so we don't need to change anything in Player.render.

js/classes/player.js

// destructure the pointer coords
rotate({ x, y }) {
  let dy = y - this.vector.y
  let dx = x - this.vector.x
  // essentially get the angle from the player to the cursor in radians
  this.angle = Math.atan2(dy, dx)
}
Enter fullscreen mode Exit fullscreen mode

But wait! When we refresh the demo, the player isn't looking at our mouse. That's because we're never actually listening for a mousemove event to get the mouse coordinates.

js/index.js

import pointer from "./libs/pointer.js"

document.body.addEventListener("mousemove", (e) => {
  let mouse = pointer(canvas, e)
  player.rotate(mouse)
})
Enter fullscreen mode Exit fullscreen mode

Now we have a moving player that can look around.

The Zombies

Like the player, let's create a Zombie class. A lot of the zombie code will look very familiar. Instead of rotating and moving around depending on user input however, it will just follow the player around.

Zombies will spawn in randomly from the right. Since they should always be facing the player, we will create a rotate function that takes in a player class and grabs their position.

js/classes/zombie.js


import { width, height } from "../config.js"
import { random } from "../libs/utils.js"

class Zombie {
  speed = 1.1
  radius = 20
  health = 5

  constructor(player) {
    this.vector = {
      x: width + this.radius,
      y: random(-this.radius, height + this.radius)
    }
    this.rotate(player)
  }

  rotate(player) {}
  update(player, zombies) {
    this.rotate(player)
  }
  render(ctx) {}
}


export default Zombie
Enter fullscreen mode Exit fullscreen mode

Rendering Zombies

Zombies will be green balls with stretched out arms. The rotating logic, body, and arms are essentially the same things found in Player.render.

js/classes/zombie.js

render(ctx) {
  ctx.save()

  let tX = this.vector.x 
  let tY = this.vector.y 
  ctx.translate(tX, tY)
  ctx.rotate(this.angle)
  ctx.translate(-tX, -tY)

  ctx.beginPath()
  ctx.fillStyle = "#00cc44"
  ctx.arc(this.vector.x, this.vector.y, this.radius, 0, Math.PI * 2)    
  ctx.fill()

  // Hands
  ctx.beginPath()
  ctx.strokeStyle = "#00cc44"
  ctx.lineCap = "round"
  ctx.lineWidth = 4

  // Right Hand
  ctx.moveTo(this.vector.x + 5, this.vector.y + this.radius - 2) 
  ctx.lineTo(this.vector.x + this.radius + 15, this.vector.y + this.radius - 5)
  ctx.stroke()

  // Left Hand
  ctx.moveTo(this.vector.x + 5, this.vector.y - this.radius + 2)
  ctx.lineTo(this.vector.x + this.radius + 15, this.vector.y - this.radius + 5)
  ctx.stroke()

  ctx.restore()
}
Enter fullscreen mode Exit fullscreen mode

Onto the Screen!

You could initialize the zombie like we did with the player, but let's store them as an array in case we want to add more.

js/classes/zombie.js

import Zombie from "./classes/zombie.js"

const player = new Player()
const zombies = [ new Zombie(player) ]

const update = () => {
  zombies.forEach(zombie => {
    zombie.update(player, zombies)
    zombie.render(ctx)
  })    

  player.update()
  player.render(ctx)
}

animate(update)
Enter fullscreen mode Exit fullscreen mode

Follow the Player

Zombies are attracted to human brains. Unfortunately, the zombie we just made just sits off screen. Let's start by making the zombie follow the player around. The main functions that let this happen are Zombie.rotate (point towards the player) and Zombie.update (calls rotate and moves in the general direction of player coordinates).

If you don't understand the Math.cos or Math.sin, intuitively this makes sense because cosine refers to x and sine refers to y. We're basically converting an angle into an x and y so we can apply it to the zombie position vector.

js/classes/zombie.js

rotate(player) {
  let dy = player.vector.y - this.vector.y
  let dx = player.vector.x - this.vector.x
  this.angle = Math.atan2(dy, dx)
}

update(player, zombies) {
  this.rotate(player)
  this.vector.x += Math.cos(this.angle) * this.speed
  this.vector.y += Math.sin(this.angle) * this.speed
}
Enter fullscreen mode Exit fullscreen mode

Although we haven't implemented a shooting system yet, we want to delete the zombie when its health reaches 0. Let's modify the update function to splice out dead zombies.

js/classes/zombie.js

update(player, zombies) {
  if(this.health <= 0) {
    zombies = zombies.splice(zombies.indexOf(this), 1)
    return
  }

  this.rotate(player)
  this.vector.x += Math.cos(this.angle) * this.speed
  this.vector.y += Math.sin(this.angle) * this.speed
}
Enter fullscreen mode Exit fullscreen mode

Bullets

The zombies are attacking! But what do we do? We have no ammo! We need to make a Bullet class so we can start killing monsters.

When we call for a new Bullet, we need to find out where the bullet should start (Bullet.vector) and what direction is should start heading (Bullet.angle). The * 40 near the vector portion shifts up the bullet near the gun, rather than spawning in directly on top of the player.

js/classes/bullet.js

import { width, height } from "../config.js"
import { distance } from "../libs/utils.js"

class Bullet {
  radius = 4
  speed = 10

  constructor(x, y, angle) {
    this.angle = {
      x: Math.cos(angle),
      y: Math.sin(angle)
    }
    this.vector = {
      x: x + this.angle.x * 40, 
      y: y + this.angle.y * 40
    }
  }

  boundary() {}
  update(bullets, zombies) {
    this.vector.x += this.angle.x * this.speed
    this.vector.y += this.angle.y * this.speed
  }
  render(ctx) {}
}

export default Bullet
Enter fullscreen mode Exit fullscreen mode

Rendering Bullets

The bullet will be a black circle. You could change this to a rectangle or a different shape, but keep in mind you'll want to rotate it depending on the angle.

js/classes/bullet.js

render(ctx) {
  ctx.beginPath()
  ctx.arc(this.vector.x, this.vector.y, this.radius, 0, Math.PI * 2)
  ctx.fillStyle = "#000"
  ctx.fill()
}
Enter fullscreen mode Exit fullscreen mode

Boundary

Bullets should be deleted when they either hit a zombie, or leave the screen's view. Let's implement the border collision first. Bullet.boundary should indicate if the bullet is out of bounds, and then remove it from the bullets array.

js/classes/bullet.js

boundary() {
  return (this.vector.x > width + this.radius ||
          this.vector.y > height + this.radius ||
          this.vector.x < 0 - this.radius ||
          this.vector.y < 0 - this.radius)
}
update(bullets, zombies) {
  if(this.boundary()) {
    bullets = bullets.splice(bullets.indexOf(this), 1)
    return
  }

  this.vector.x += this.angle.x * this.speed
  this.vector.y += this.angle.y * this.speed
}
Enter fullscreen mode Exit fullscreen mode

Click to Fire

Every time we click the screen we should fire off a new bullet. After importing the Bullet class into the main script, we'll make a bullets array that we can push a new Bullet to every time a user clicks the screen. This way, we can loop through and update each bullet.

If you recall just above, we need to pass in the bullets and zombies array directly into the Bullet.update function so we can remove bullets as needed.

js/index.js

import Bullet from "./classes/bullet.js"

const bullets = []

document.body.addEventListener("click", () => {
  bullets.push(
    new Bullet(player.vector.x, player.vector.y, player.angle)
  )
})

const update = () => {
  bullets.forEach(bullet => {
    bullet.update(bullets, zombies)
    bullet.render(ctx)
  })
}

animate(update)
Enter fullscreen mode Exit fullscreen mode

Kill the Zombies!

At the moment, bullets pass straight through zombies.

We can loop through each zombie and bullet and check the distance between them. If the distance is lower than the zombie's radius, our bullet hit the target and we need to decrease the zombie's HP and delete the bullet.

js/classes/bullet.js

update(bullets, zombies) {
  if(this.boundary()) {
    bullets = bullets.splice(bullets.indexOf(this), 1)
    return
  }

  for(const bullet of bullets) {
    for(const zombie of zombies) {
      let d = distance(zombie.vector.x, zombie.vector.y, this.vector.x, this.vector.y)
      if(d < zombie.radius) {
        bullets = bullets.splice(bullets.indexOf(this), 1)
        zombie.health -- 
        return
      }
    }
  }

  this.vector.x += this.angle.x * this.speed
  this.vector.y += this.angle.y * this.speed
}
Enter fullscreen mode Exit fullscreen mode

Try shooting at a zombie 5 times. Hopefully, the bullets and zombie will disappear.

Bonus: Infinite Waves

One zombie is boring. How about we spawn in a zombie every three seconds?
js/index.js

setInterval(() => {
    zombies.push(new Zombie(player))
}, 3 * 1000)
Enter fullscreen mode Exit fullscreen mode

Closing

Now we have a fully functional zombie shooting game. Hopefully this gave you a brief introduction to game development with the HTML5 canvas. Currently, nothing happens when a zombie touches you, but it shouldn't be too hard to implement a player HP bar (look back on the bullet and zombie collision code). I look forward to how you extend or optimize this game!

Discussion (2)

Collapse
marcoslazo profile image
Marcos Lazo

Well explained, congrats!

Collapse
rishitkhandelwal profile image
Rishit Khandelwal

interesting :)