Building A Multiplayer Game With Three.Js + WebSockets

June 1, 2019

We'll be using these tools/libraries to build a multiplayer game.

  • Three.js. A framework built on top of WebGL that makes it easier to create graphics in the browswer.
  • Cannon.js. A physics engine that pairs well with Three.js.
  • WS - A lightweight WebSocket client for node and the browser (Alternatives: Socket.io).
  • RxJs - a library for using observables (event streams like keydown events).

A Quick-And-Dirty Intro to Three.js

Three.js is a framework built around WebGL that makes it easy to create graphics in the browser. It uses the canvas element.

Every three.js project has these basic elements:

  • scene
  • camera
  • renderer

Scene

This is the most global three.js namespace. When objects are added to the scene, they can be found in scene.children. It's initialized with:

var scene = new THREE.Scene();

Objects are added to the scene with:

scene.add(obj)

Camera

The camera is the vantage point the scene is viewed from.

Here's how a camera is created:

// Camera frustum vertical field of view
var fov = 75;
// Camera frustum aspect ratio
var aspect = window.innerWidth / window.innerHeight;
// Camera frustum near plane
var near = 0.1;
// Camera frustum far plane
var far = 1000;
var camera = new THREE.PerspectiveCamera(fov, aspect, near, far);

Renderer

In three.js we create a new WebGLRenderer and then append the renderer.domElement to the document. The renderer.domElement is just an HTML canvas element.

var renderer = new THREE.WebGLRenderer({
  antialias: true
});
// make the canvas element the dimensions of the screen
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

In addition to appending a canvas element to the document, you can use a pre-existing canvas element.

The three.js renderer has a method called render(). After the renderer is created we call this method:

function render() {
  // call render again before the next repaint
  requestAnimationFrame(render)
  renderer.render(scene, camera);
}

requestAnimationFrame requests that the browser call the render() function to update an animation before the next repaint. A reference to the render function is passed to requestAnimationFrame as an argument.

Adding Objects to the Scene

After creating a scene, camera and renderer, you'll want to create some objects (meshes) and add them to the scene. A mesh is a composite of a material and a geometry. Properties like texture belong to the material.

let texture = (new THREE.TextureLoader()).load('assets/box.jpg')
let boxMesh = new THREE.Mesh(
  new THREE.BoxGeometry(1, 1, 1, 1),
  new THREE.MeshLambertMaterial({ 
    map: texture, 
    vertexColors: THREE.VertexColors 
  })
) 
scene.add(boxMesh)

Terrain

To create some basic terrain, we'll use THREE.PlaneGeometry(width, height, segments, segments) and then adjust the elevation of the vertices in the geometry.

The first two arguments are the width and height of the plane, respectively. The latter two are the number of segments.

The snippet below creates a 20 x 20 grid of tiles.

const mesh = new THREE.Mesh(
  new THREE.PlaneGeometry(10, 10, 20, 20),
  new THREE.MeshNormalMaterial()
)

We create a Three.js mesh object by combining geometry and material objects.

Right now we've made a flat plane. For terrain, we need to adjust height of the plane at each vertex. You can find a reference to the mesh's geometry in mesh.geometry.

So mesh.geometry.vertices is an array of vertices that comprise the plane. To create terrain we can loop through the vertices and adjust the altitude.

for(let i = 0; i < mesh.geometry.vertices.length; i++) {
  mesh.geometry.vertices[i].setZ(Math.random())
}

Oh, and I forgot to mention that mesh.geometry.vertices is an array of Vectors. Vectors are special Three.js objects that store x, y, z coordinates. But they're also so much more; they come with some useful methods like clone(), add(), copy() and so forth. In the above code snippet, setZ() does what you'd expect: it sets the value of the z coordinate.

Link to gist

Mutating State in Three.js

Most operations in Three.js mutate state. E.g., consider the following code:

let vec = new THREE.Vector(0, 0, 0)
for(let i = 0; i < 3; i++) {
  vec.add(new THREE.Vector(0, 0, 1))
}
console.log(vec)

What does console.log(vec) output at the end of the loop? The answer is {x: 0, y: 0, z: 3}. Most operations mutate state. Three.js objects have a clone() method that returns a copy. You can use clone to avoid mutating the original.

Moving A Hero With the Keyboard

One of the first challenges of creating some games is figuring out how to move the hero.

We can translate a sprite along the surface of the terrain by listening for onKeyDown and onKeyUp events and then adjusting the sprite's position.

Here's a naive approach:

let movingRight;
let movingLeft;
let movingDown;
let movingUp;
window.addEventListener('onkeydown', function(evt) {
  let { key } = evt
  switch(key) {
    case 'ArrowRight'
      movingRight = true;
      break;
   case 'ArrowLeft'
      movingLeft = true;
      break;
    ...
  }
})
function render() {
  // queue up a new animation frame
  requestAnimationFrame(render)
  // move in the +x direction
  if(movingRight) sprite.position.add(1,0,0)
  // move in the -x direction
  if(movingLeft) sprite.position.add(-1,0,0)
  ...
  renderer.render(
    scene,
    camera
  )
}
  • requestAnimationFrame does exactly what you would expect: it adds to the top of the stack.
  • We pass a reference to the enclosing function render() to requestAnimationFrame; render() will be a called again at a later time (a few milliseconds later).

Tapping Into Keyboard Event Streams With Observables

An observable is just a stream of events. Here are some concrete examples:

  • A sequence of clicks
  • A sequence of mouse movements
  • Requests to an API

These are all just streams of events - aka - observables. RxJs is a third-party library available on npm that helps you manipulate such event streams (observables).

Observables are useful for onkeydown events because they can help simplify the control flow and pair down on the business logic.

We might be tempted to write bulky switch statements for onkeydown and onkeyup events. But with RxJs we can manipulate the stream of keyboard events and then subscribe to changes.

import Rx from 'rxjs/Rx';
Rx.Observable.fromEvent(document, 'keydown')
  .map(e => e.key)
  .subscribe(key => { console.log(key) })

Doesn't look too bad right?

In the above the snippet, we:

  1. Create an observable from the document.onkeydown event.
  2. Map the observable to the key
  3. Subscribe to changes

So far, this doesn't look very different from the plain Javascript approach.

const keyBindings = {
  ArrowRight: new THREE.Vector3(0.1, 0, 0),
  ArrowLeft: new THREE.Vector3(-0.1, 0, 0),
  ArrowUp: new THREE.Vector3(0, 0.1, 0),
  ArrowDown: new THREE.Vector3(0, -0.1, 0),
}
// create an observable for the stream of keydown events
let keydown = Rx.Observable
  .fromEvent(document, 'keydown')
  .map(evt => keyBindings[evt.key])
// subcribe to changes
keydown.subscribe((increment) => {
  mesh.position.add(increment)
})

Link to Gist

Here's another approach using RxJs that uses a store to maintain application state.

Earlier we mapped event streams to value, e.g., map(e => e.key). With the stateful approach we map the stream of events to a state changing function.

let stream = Rx.Observable.fromEvent(document, 'keydown')
  .map(e => state => Object.assign({}, state, {
    key: e.key
  }))
// We create an object with our initial state. Whenever a new state 
// change function is received we call it and pass the state. The 
// new state is returned and ready to be changed again on the next keydown // event. RxJs' scan is kind of like reduce which you may be more 
// familiar with.
let state = stream.scan((state, changeFn) => changeFn(state), {
  key: null
})
state.subscribe(function(state) {
  document.querySelector('body').textContent = JSON.stringify(state)
})

So far we can move a hero around on a flat plane using a keyboard. Our hero only stays on the surface because we only adjust position on the x and y axises. But terrain has height. There are a few ways to tackle this problem.

Getting Z

For each new x and y position our character we could get the magnitude of the z-dimension (height) of our terrain.

 let terrainMesh = new THREE.Mesh(...)
 
function getZ(x, y) {
  let { vertices } = terrainMesh.geometry
  return vertices.find(vertex => 
    vertex.x === x && vertex.y === y
  )
} 
 ...
 stream.subscribe(increment => {
   let z = getZ(increment.x, increment.y)
   mesh.position.add(increment.setZ(z))
 })