Writing a 3D space rail shooter in Three.js, Part 3

Martin Naumann

October 23rd, 2015

In the course of this three part series, you will learn how to write a simple 3D space shooter game with Three.js.

The game will introduce the basic concepts of a Three.js application, how to write modular code and the core principles of a game, such as camera, player motion and collision detection.

In Part 1 we set up our package and created the world of our game. In Part 2, we added the spaceship and the asteroids for our game. In this final Part 3 of the series, we will set the collision detection, add weapons to our craft and add a way to score and manage our game health as well.

Collisions make things go boom

Okay, now we'll need to set up collision detection and shooting. Let's start with collision detection!

We will be using a technique called hitbox, where we'll create bounding boxes for the asteroids and the spaceship and check for intersections. Luckily, Three.js has a THREE.Box3 class to help us with this.

The additions to the Player module:

var Player = function(parent) {
var loader = newObjMtlLoader(), self = this
this.loaded = false
this.hitbox = newTHREE.Box3()
this.update = function() {
   if(!spaceship) return
   this.hitbox.setFromObject(spaceship)
}

This adds the hitbox and an update method that updates the hitbox by using the spaceship object to get dimensions and position for the box.

Now we'll adjust the Asteroid module to do the same:

var Asteroid = function(rockType) {
var mesh = newTHREE.Object3D(), self = this

// Speed of motion and rotation
mesh.velocity = Math.random() * 2 + 2
mesh.vRotation = newTHREE.Vector3(Math.random(), Math.random(), Math.random())

this.hitbox = newTHREE.Box3()

and tweak the update method:

this.update = function(z) {
   mesh.position.z += mesh.velocity
   mesh.rotation.x += mesh.vRotation.x * 0.02;
   mesh.rotation.y += mesh.vRotation.y * 0.02;
   mesh.rotation.z += mesh.vRotation.z * 0.02;

   if(mesh.children.length > 0) this.hitbox.setFromObject(mesh.children[0])

   if(mesh.position.z > z) {
     this.reset(z)
   }
}

You may have noticed the reset method that isn't implemented yet. It'll come in handy later - so let's make that method:

this.reset = function(z) {
   mesh.velocity = Math.random() * 2 + 2
   mesh.position.set(-50 + Math.random() * 100, -50 + Math.random() * 100, z - 1500 - Math.random() * 1500)
}

This method allows us to quickly push an asteroid back into action whenever we need to.

On to the render loop:

function render() {
cam.position.z -= 1
tunnel.update(cam.position.z)
player.update()
for(var i=0;i<NUM_ASTEROIDS;i++) {
   if(!asteroids[i].loaded) continue

   asteroids[i].update(cam.position.z)
   if(player.loaded && player.hitbox.isIntersectionBox(asteroids[i].hitbox)) {
     asteroids[i].reset(cam.position.z)
   }
}
}

So for each asteroid that is loaded, we're checking if the hitbox of our player is intersecting (i.e. colliding) with the hitbox of the asteroid. If so, we'll reset (i.e. push into the vortex ahead of us) the asteroid, based on the camera offset.

Pew! Pew! Pew!

Now on to get us some weaponry!

Let's create a Shot module:

var THREE = require('three')

var shotMtl = newTHREE.MeshBasicMaterial({
color: 0xff0000,
transparent: true,
opacity: 0.5
})

var Shot = function(initialPos) {
this.mesh = newTHREE.Mesh(
   newTHREE.SphereGeometry(3, 16, 16),
   shotMtl
)

this.mesh.position.copy(initialPos)

this.getMesh = function() {
   returnthis.mesh
}

this.update = function(z) {
   this.mesh.position.z -= 5

   if(Math.abs(this.mesh.position.z - z) > 1000) {
     returnfalse
     deletethis.mesh
   }

   returntrue
}

returnthis
}

module.exports = Shot

In this module we're creating a translucent, red sphere, spawned at the initial position given to the constructor function.

The update method is a bit different from those we've seen so far as it returns either true (still alive) or false (dead, remove now) based on the position.

Once the shot is too far from the camera, it gets cleaned up.

Now back to our main.js:

var shots = []

functionrender() {
cam.position.z -= 1
tunnel.update(cam.position.z)
player.update()

for(var i=0; i<shots.length; i++) {
   if(!shots[i].update(cam.position.z)) {
     World.getScene().remove(shots[i].getMesh())
     shots.splice(i, 1)
   }
}

This snippet is adding in a loop over all the shots and updates them, removing them if needed.

But we also have to check for collisions with the asteroids:

for(var i=0;i<NUM_ASTEROIDS;i++) {
   if(!asteroids[i].loaded) continue

   asteroids[i].update(cam.position.z)
   if(player.loaded && player.hitbox.isIntersectionBox(asteroids[i].hitbox)) {
     asteroids[i].reset(cam.position.z)
   }

   for(var j=0; j<shots.length; j++) {
     if(asteroids[i].hitbox.isIntersectionBox(shots[j].hitbox)) {
       asteroids[i].reset(cam.position.z)
       World.getScene().remove(shots[j].getMesh())
       shots.splice(j, 1)
       break
     }
   }

}

Last but not least we need some code to take keyboard input to fire the shots:

window.addEventListener('keyup', function(e) {
switch(e.keyCode) {
   case32: // Space
     var shipPosition = cam.position.clone()
     shipPosition.sub(newTHREE.Vector3(0, 25, 100))

     var shot = newShot(shipPosition)
     shots.push(shot)
     World.add(shot.getMesh())
   break
}
})

This code - when the spacebar key is pressed - is adding a new shot to the array, which will then be updated in the render loop.

Move it, move it!

Cool, but while we're at the keyboard handler, let's make things moving a bit more!

window.addEventListener('keydown', function(e) {
if(e.keyCode == 37) {
   cam.position.x -= 5
} elseif(e.keyCode == 39) {
   cam.position.x += 5
}

if(e.keyCode == 38) {
   cam.position.y += 5
} elseif(e.keyCode == 40) {
   cam.position.y -= 5
}
})

This code uses the arrow keys to move the camera around.

Finishing touches

Now the last bits come into play: Score and health management as well.

Start with defining the two variables in main.js:

var score = 0, health = 100

and change these values where appropriate:

   if(player.loaded && player.hitbox.isIntersectionBox(asteroids[i].hitbox)) {
     asteroids[i].reset(cam.position.z)
     health -= 20
     document.getElementById('health').textContent = health
     if(health < 1) {
       World.pause()
       alert('Game over! You scored ' + score + ' points')
       window.location.reload()
     }
   }

This decreases the health by 20 points whenever the spaceship hits an asteroid and shows a "Game over" box and reloads the game afterwards.

   for(var j=0; j<shots.length; j++) {
     if(asteroids[i].hitbox.isIntersectionBox(shots[j].hitbox)) {
       score += 10
       document.getElementById("score").textContent = score
       asteroids[i].reset(cam.position.z)
       World.getScene().remove(shots[j].getMesh())
       shots.splice(j, 1)
       break
     }
   }

This increases the score by 10 whenever a shot hits an asteroid.

You may have noticed the two document.getElementById calls that will not work just yet. Those are for two UI elements that we'll add to the index.html to show the player the current health and score situation:

<body>
<div id="bar">
   Health: <span id="health">100</span>%
   &nbsp;&nbsp;
   Score: <span id="score">0</span>
</div>
<script src="app.js"></script>
</body>

And throw in some CSS, too:

@import url(http://fonts.googleapis.com/css?family=Orbitron);

#bar{
font-family: Orbitron, sans-serif;
position:absolute;
left:0;
right:0;
height:1.5em;
background:black;
color:white;
line-height:1.5em;
}

Wrap up

With all 3 Parts of this series, we now with the help of Three.js have a basic 3D game running in the browser.

There's a bunch of improvements to be made - the controls, mobile input compatibility and performance, but the basic concepts are in place.

Now have fun playing!

About the author

Martin Naumann is an open source contributor and web evangelist by heart from Zurich with a decade of experience from the trenches of software engineering in multiple fields. He works as a software engineer at Archilogic in front and backend. He devotes his time to moving the web forward, fixing problems, building applications and systems and breaking things for fun & profit. Martin believes in the web platform and is working with bleeding edge technologies that will allow the web to prosper.

Get your free eBook today

Continue on your journey as a game developer by deciding which game engine and language suits you best with the help of our free eBook.
comments powered by Disqus