Creating a Simple 3D Endless Runner Game Using Three.js

The web platform has had a tremendous growth in recent times with the help of HTML5, WebGL, and the increased power of the current generation of devices. Now mobile devices and browsers are capable of delivering high-performing content both in 2D and 3D. The familiarity of JavaScript (JS) as a scripting language has also been a driving factor, after the demise of the Flash web platform.

Creating a Simple 3D Endless Runner Game Using Three.js

Creating a Simple 3D Endless Runner Game Using Three.js

Most web developers are well aware of how complicated the JS ecosystem is with all the various frameworks and standards available, which could sometimes be overwhelming to a new developer. But when it comes to 3D, the choices are straightforward, thanks to Mr.Doob. His Three.js is currently the best option out there to create high-performing 3D WebGL content. Another powerful alternative is Babylon.js, which could also be used to make 3D games.

In this tutorial, you’ll learn to create a simple endless runner style native web 3D game using the powerful Three.js framework. You will use the arrow keys to control a snowball rolling down a mountainside in order to dodge the trees in your path. There is no art involved, and all visuals are created in code.

1. Basic 3D Scene

ThemeKeeper Tuts+ already has a few tutorials which could get you started with Three.js. Here are some of them to get you started.

Let’s create a basic 3D scene first, as shown here where there is a rotating cube. You can use mouse drag to orbit around the cube.

Any graphic displayed on a two-dimensional screen is practically 2D in nature, with a few important elements which provide the 3D illusion: the lighting, the shading, the shadows, and the 3D to 2D projection magic which happens via the camera. In the above scene, we enable effective lighting using these lines of code.

camera = new THREE.PerspectiveCamera( 60, sceneWidth / sceneHeight, 0.1, 1000 );//perspective camera
renderer = new THREE.WebGLRenderer({alpha:true});//renderer with transparent backdrop
renderer.shadowMap.enabled = true;//enable shadow
renderer.shadowMap.type = THREE.PCFSoftShadowMap;

//...
hero = new THREE.Mesh( heroGeometry, heroMaterial );
hero.castShadow=true;
hero.receiveShadow=false;
//...
ground.receiveShadow = true;
ground.castShadow=false;
//..
sun = new THREE.DirectionalLight( 0xffffff, 0.8);
sun.position.set( 0,4,1 );
sun.castShadow = true;
scene.add(sun);
//Set up shadow properties for the sun light
sun.shadow.mapSize.width = 256;
sun.shadow.mapSize.height = 256;
sun.shadow.camera.near = 0.5;
sun.shadow.camera.far = 50 ;

The renderer needs to have shadowMap enabled, the scene needs to have a light with castShadow enabled, and all 3D objects need the castShadow and receiveShadow properties set appropriately. For proper shading to happen, we should also use the MeshStandardMaterial or a more feature-rich material for our 3D objects. The camera is controlled using the nifty OrbitControls script. I would recommend playing around with the basic 3D scene by adding more primitive shapes or playing with the lighting, etc., before proceeding with the tutorial.

2. The Endless Runner Concept

There are many types of endless runner games, and ours is an ‘endless roller’. We will create a game where a snowball is rolling down an endless mountainside where we use the arrow keys to dodge the incoming trees. One interesting thing is that this simple game will not involve any art assets, as all the components would be created by code. Here is the full game to play around.

3. Components of the Game

The main components or elements of the game are:

  • the rolling snowball
  • the random trees
  • the scrolling ground
  • the distance fog
  • the collision effect

We will explore each of these one by one in the following section.

The Fog

The fog is a property of the 3D scene in Three. It is always a handy trick to use in order to simulate depth or show a horizon. The colour of the fog is important for the illusion to work properly and depends on the colour of the scene and lighting. As you can see in the code below, we also set the renderer‘s clearColor value to be close to the colour of the fog.

scene = new THREE.Scene();
scene.fog = new THREE.FogExp2( 0xf0fff0, 0.14 );
camera = new THREE.PerspectiveCamera( 60, sceneWidth / sceneHeight, 0.1, 1000 );//perspective camera
renderer = new THREE.WebGLRenderer({alpha:true});//renderer with transparent backdrop
renderer.setClearColor(0xfffafa, 1); 

In order to match the ambience, we are also using similar colour values to the lights used in the scene. Every ambient colour is a different shade of white which gels together to create the necessary effect.

var hemisphereLight = new THREE.HemisphereLight(0xfffafa,0x000000, .9)
scene.add(hemisphereLight);
sun = new THREE.DirectionalLight( 0xcdc1c5, 0.9);
sun.position.set( 12,6,-7 );
sun.castShadow = true;
scene.add(sun);

The Snowball

Our snowball is a DodecahedronGeometry three primitive shape created as shown below.

var sphereGeometry = new THREE.DodecahedronGeometry( heroRadius, 1);
var sphereMaterial = new THREE.MeshStandardMaterial( { color: 0xe5f2f2 ,shading:THREE.FlatShading} )
heroSphere = new THREE.Mesh( sphereGeometry, sphereMaterial );

For all 3D elements in this game, we are using THREE.FlatShading to get the desired low-poly look.

The Scrolling Mountain

The scrolling ground named rollingGroundSphere is a big SphereGeometry primitive, and we rotate it on the x axis to create the moving ground illusion. The snowball does not really roll over anything; we are just creating the illusion by keeping the ground sphere rolling while keeping the snowball stationary.

A normal sphere primitive will look very smooth and therefore won’t provide the necessary ruggedness needed for the mountain slope. So we do some vertex manipulations to change the smooth sphere surface into a rugged terrain. Here is the corresponding code followed by an explanation.

var sides=40;
var tiers=40;
var sphereGeometry = new THREE.SphereGeometry( worldRadius, sides,tiers);
var sphereMaterial = new THREE.MeshStandardMaterial( { color: 0xfffafa ,shading:THREE.FlatShading} )
var vertexIndex;
var vertexVector= new THREE.Vector3();
var nextVertexVector= new THREE.Vector3();
var firstVertexVector= new THREE.Vector3();
var offset= new THREE.Vector3();
var currentTier=1;
var lerpValue=0.5;
var heightValue;
var maxHeight=0.07;
for(var j=1;j<tiers-2;j++){
	currentTier=j;
	for(var i=0;i<sides;i++){
		vertexIndex=(currentTier*sides)+1;
		vertexVector=sphereGeometry.vertices[i+vertexIndex].clone();
		if(j%2!==0){
			if(i===0){
				firstVertexVector=vertexVector.clone();
			}
			nextVertexVector=sphereGeometry.vertices[i+vertexIndex+1].clone();
			if(i==sides-1){
				nextVertexVector=firstVertexVector;
			}
			lerpValue=(Math.random()*(0.75-0.25))+0.25;
			vertexVector.lerp(nextVertexVector,lerpValue);
		}
		heightValue=(Math.random()*maxHeight)-(maxHeight/2);
		offset=vertexVector.clone().normalize().multiplyScalar(heightValue);
		sphereGeometry.vertices[i+vertexIndex]=(vertexVector.add(offset));
	}
}
rollingGroundSphere = new THREE.Mesh( sphereGeometry, sphereMaterial );

We are creating a sphere primitive with 40 horizontal segments (sides) and 40 vertical segments (tiers). Each vertex of a three geometry can be accessed via the vertices array property. We loop through all the tiers between the extreme top and extreme bottom vertices to do our vertex manipulations. Each tier of the sphere geometry contains exactly sides number of vertices, which forms a closed ring around the sphere.

The first step is to rotate every odd ring of vertices to break the uniformity of the surface contours. We move every vertex in the ring by a random fraction between 0.25 and 0.75 of the distance to the next vertex. As a result of this, the vertical vertices of the sphere are not aligned in a straight line anymore, and we get a nice zigzag contour.

As the second step, we provide each vertex with a random height adjustment aligned with the normal at the vertex, irrespective of the tier to which it belongs. This results in an uneven and rugged surface. I hope the vector mathematics used here are straightforward once you consider that the centre of the sphere is considered the origin (0,0).

The Trees

The trees appear outside our rolling track to add depth to the world, and inside as obstacles. Creating the tree is a bit more complicated than the rugged ground but follows the same logic. We use a ConeGeometry primitive to create the top green part of the tree and a CylinderGeometry to create the bottom trunk part.

For the top part, we loop through each tier of vertices and expand the ring of vertices followed by shrinking down the next ring. The following code shows the blowUpTree method used to expand the alternative ring of vertices outwards and the tightenTree method used to shrink down the next ring of vertices.

function createTree(){
    var sides=8;
	var tiers=6;
	var scalarMultiplier=(Math.random()*(0.25-0.1))+0.05;
	var midPointVector= new THREE.Vector3();
	var vertexVector= new THREE.Vector3();
	var treeGeometry = new THREE.ConeGeometry( 0.5, 1, sides, tiers);
	var treeMaterial = new THREE.MeshStandardMaterial( { color: 0x33ff33,shading:THREE.FlatShading  } );
	var offset;
	midPointVector=treeGeometry.vertices[0].clone();
	var currentTier=0;
	var vertexIndex;
	blowUpTree(treeGeometry.vertices,sides,0,scalarMultiplier);
	tightenTree(treeGeometry.vertices,sides,1);
	blowUpTree(treeGeometry.vertices,sides,2,scalarMultiplier*1.1,true);
	tightenTree(treeGeometry.vertices,sides,3);
	blowUpTree(treeGeometry.vertices,sides,4,scalarMultiplier*1.2);
	tightenTree(treeGeometry.vertices,sides,5);
	var treeTop = new THREE.Mesh( treeGeometry, treeMaterial );
	treeTop.castShadow=true;
	treeTop.receiveShadow=false;
	treeTop.position.y=0.9;
	treeTop.rotation.y=(Math.random()*(Math.PI));
	var treeTrunkGeometry = new THREE.CylinderGeometry( 0.1, 0.1,0.5);
	var trunkMaterial = new THREE.MeshStandardMaterial( { color: 0x886633,shading:THREE.FlatShading  } );
	var treeTrunk = new THREE.Mesh( treeTrunkGeometry, trunkMaterial );
	treeTrunk.position.y=0.25;
	var tree =new THREE.Object3D();
	tree.add(treeTrunk);
	tree.add(treeTop);
	return tree;
}
function blowUpTree(vertices,sides,currentTier,scalarMultiplier,odd){
	var vertexIndex;
	var vertexVector= new THREE.Vector3();
	var midPointVector=vertices[0].clone();
	var offset;
	for(var i=0;i<sides;i++){
		vertexIndex=(currentTier*sides)+1;
		vertexVector=vertices[i+vertexIndex].clone();
		midPointVector.y=vertexVector.y;
		offset=vertexVector.sub(midPointVector);
		if(odd){
			if(i%2===0){
				offset.normalize().multiplyScalar(scalarMultiplier/6);
				vertices[i+vertexIndex].add(offset);
			}else{
				offset.normalize().multiplyScalar(scalarMultiplier);
				vertices[i+vertexIndex].add(offset);
				vertices[i+vertexIndex].y=vertices[i+vertexIndex+sides].y+0.05;
			}
		}else{
			if(i%2!==0){
				offset.normalize().multiplyScalar(scalarMultiplier/6);
				vertices[i+vertexIndex].add(offset);
			}else{
				offset.normalize().multiplyScalar(scalarMultiplier);
				vertices[i+vertexIndex].add(offset);
				vertices[i+vertexIndex].y=vertices[i+vertexIndex+sides].y+0.05;
			}
		}
	}
}
function tightenTree(vertices,sides,currentTier){
	var vertexIndex;
	var vertexVector= new THREE.Vector3();
	var midPointVector=vertices[0].clone();
	var offset;
	for(var i=0;i<sides;i++){
		vertexIndex=(currentTier*sides)+1;
		vertexVector=vertices[i+vertexIndex].clone();
		midPointVector.y=vertexVector.y;
		offset=vertexVector.sub(midPointVector);
		offset.normalize().multiplyScalar(0.06);
		vertices[i+vertexIndex].sub(offset);
	}
}

The blowUpTree method pushes out every alternative vertex in a ring of vertices while keeping the other vertices in the ring at a lesser height. This creates the pointy branches on the tree. If we use the odd vertices in one tier then we use the even vertices in the next tier so that the uniformity is broken. Once the complete tree is formed, we give it a random rotation on the y axis to make it look slightly different.

The Explosion Effect

The block pixel explosion effect is not the most elegant one we could use, but it certainly performs well. This particular particle effect is actually a 3D geometry which is manipulated to look like an effect using the THREE.Points class.

function addExplosion(){
    particleGeometry = new THREE.Geometry();
	for (var i = 0; i < particleCount; i ++ ) {
		var vertex = new THREE.Vector3();
		particleGeometry.vertices.push( vertex );
	}
	var pMaterial = new THREE.ParticleBasicMaterial({
	  color: 0xfffafa,
	  size: 0.2
	});
	particles = new THREE.Points( particleGeometry, pMaterial );
	scene.add( particles );
	particles.visible=false;
}
function explode(){
	particles.position.y=2;
	particles.position.z=4.8;
	particles.position.x=heroSphere.position.x;
	for (var i = 0; i < particleCount; i ++ ) {
		var vertex = new THREE.Vector3();
		vertex.x = -0.2+Math.random() * 0.4;
		vertex.y = -0.2+Math.random() * 0.4 ;
		vertex.z = -0.2+Math.random() * 0.4;
		particleGeometry.vertices[i]=vertex;
	}
	explosionPower=1.07;
	particles.visible=true;
}
function doExplosionLogic(){//called in update
    if(!particles.visible)return;
    for (var i = 0; i < particleCount; i ++ ) {
		particleGeometry.vertices[i].multiplyScalar(explosionPower);
	}
	if(explosionPower>1.005){
		explosionPower-=0.001;
	}else{
		particles.visible=false;
	}
	particleGeometry.verticesNeedUpdate = true;
}

The addExplosion method adds 20 vertices to the vertices array of the particleGeometry. The explode method is called when we need the effect to run, which randomly positions each vertex of the geometry. The doExplosionLogic gets called in the update method if the particle object is visible, where we move each vertex outwards. Each vertex in a points object gets rendered as a square block.

4. The Gameplay

Now that we know how to create each of the items needed for the game, let’s get into the gameplay. The main gameplay elements are:

  • the game loop
  • the placement of the trees
  • the user interaction
  • the collision detection

Let’s analyse those in detail.

The Game Loop

All the core game mechanic happens in the game loop, which in our case is the update method. We call it for the first time from the init method, which gets called on window load. After this, it hooks onto the document render loop using the requestAnimationFrame method so that it gets called repeatedly.

function update(){
    rollingGroundSphere.rotation.x += rollingSpeed;
    heroSphere.rotation.x -= heroRollingSpeed;
    if(heroSphere.position.y<=heroBaseY){
        jumping=false;
    	bounceValue=(Math.random()*0.04)+0.005;
    }
    heroSphere.position.y+=bounceValue;
    heroSphere.position.x=THREE.Math.lerp(heroSphere.position.x,currentLane, 2*clock.getDelta());//clock.getElapsedTime());
    bounceValue-=gravity;
    if(clock.getElapsedTime()>treeReleaseInterval){
    	clock.start();
    	addPathTree();
    	if(!hasCollided){
            score+=2*treeReleaseInterval;
            scoreText.innerHTML=score.toString();
        }
    }
    doTreeLogic();
    doExplosionLogic();
    render();
    requestAnimationFrame(update);//request next update
}
function render(){
    renderer.render(scene, camera);//draw
}

In update, we call the render method, which uses the renderer to draw the scene. We call the doTreeLogic method, which checks for collision and also removes the trees once they have gone out of view.

The snowball and the ground spheres get rotated while we also add a random bouncing logic to the snowball. New trees are placed in the path by calling addPathTree after a pre-defined time has elapsed. Time is tracked using a THREE.Clock object. We also update the score unless a collision has occurred.

Placement of the Trees

One set of trees is placed outside the rolling track to create the world using the addWorldTrees method. All trees are added as a child of the rollingGroundSphere so that they also move when we rotate the sphere.

function addWorldTrees(){
    var numTrees=36;
	var gap=6.28/36;
	for(var i=0;i<numTrees;i++){
		addTree(false,i*gap, true);
		addTree(false,i*gap, false);
	}
}
function addTree(inPath, row, isLeft){
	var newTree;
	if(inPath){
		if(treesPool.length===0)return;
		newTree=treesPool.pop();
		newTree.visible=true;
		//console.log("add tree");
		treesInPath.push(newTree);
		sphericalHelper.set( worldRadius-0.3, pathAngleValues[row], -rollingGroundSphere.rotation.x+4 );
	}else{
		newTree=createTree();
		var forestAreaAngle=0;//[1.52,1.57,1.62];
		if(isLeft){
			forestAreaAngle=1.68+Math.random()*0.1;
		}else{
			forestAreaAngle=1.46-Math.random()*0.1;
		}
		sphericalHelper.set( worldRadius-0.3, forestAreaAngle, row );
	}
	newTree.position.setFromSpherical( sphericalHelper );
	var rollingGroundVector=rollingGroundSphere.position.clone().normalize();
	var treeVector=newTree.position.clone().normalize();
	newTree.quaternion.setFromUnitVectors(treeVector,rollingGroundVector);
	newTree.rotation.x+=(Math.random()*(2*Math.PI/10))+-Math.PI/10;
	
	rollingGroundSphere.add(newTree);
}

To plant world trees, we call the addTree method by passing values around the circumference of our ground sphere. The sphericalHelper utility helps us find the position on the surface of a sphere.

To plant trees on the path, we will make use of a pool of trees which are created on start using the createTreesPool method. We also have pre-defined angle values for each path on the sphere stored in the pathAngleValues array.

pathAngleValues=[1.52,1.57,1.62];
//..
function createTreesPool(){
    var maxTreesInPool=10;
	var newTree;
	for(var i=0; i<maxTreesInPool;i++){
		newTree=createTree();
		treesPool.push(newTree);
	}
}
function addPathTree(){
    var options=[0,1,2];
	var lane= Math.floor(Math.random()*3);
	addTree(true,lane);
	options.splice(lane,1);
	if(Math.random()>0.5){
		lane= Math.floor(Math.random()*2);
		addTree(true,options[lane]);
	}
}

The addPathTree method is called from update when enough time has elapsed after planting the last tree. It in turn calls the addTree method shown earlier with a different set of parameters where the tree gets placed in the selected path. The doTreeLogic method will return the tree to the pool once it goes out of view.

User Interaction

We are adding a listener to the document to look for relevant keyboard events. The handleKeyDown method sets the currentLane value if the right or left arrow keys are pressed or sets the bounceValue value if up arrow is pressed.

document.onkeydown = handleKeyDown;
//..
function handleKeyDown(keyEvent){
    if(jumping)return;
	var validMove=true;
	if ( keyEvent.keyCode === 37) {//left
		if(currentLane==middleLane){
			currentLane=leftLane;
		}else if(currentLane==rightLane){
			currentLane=middleLane;
		}else{
			validMove=false;	
		}
	} else if ( keyEvent.keyCode === 39) {//right
		if(currentLane==middleLane){
			currentLane=rightLane;
		}else if(currentLane==leftLane){
			currentLane=middleLane;
		}else{
			validMove=false;	
		}
	}else{
		if ( keyEvent.keyCode === 38){//up, jump
			bounceValue=0.1;
			jumping=true;
		}
		validMove=false;
	}
    if(validMove){
		jumping=true;
		bounceValue=0.06;
	}
}

In update, the x position of our snowball is slowly incremented to reach the currentLane position there by switching lanes.

Collision Detection

There is no real physics involved in this particular game, although we could use various physics frameworks for our collision detection purpose. But as you are well aware, a physics engine adds a lot of performance overhead to our game, and we should always try to see if we can avoid it.

In our case, we just calculate the distance between our snowball and each tree to trigger a collision if they are very close. This happens in the doTreeLogic method, which gets called from update.

function doTreeLogic(){
    var oneTree;
	var treePos = new THREE.Vector3();
	treesInPath.forEach( function ( element, index ) {
		oneTree=treesInPath[ index ];
		treePos.setFromMatrixPosition( oneTree.matrixWorld );
		if(treePos.distanceTo(heroSphere.position)<=0.6){
			console.log("hit");
			hasCollided=true;
			explode();
		}
	});
    //..
}

As you may have noticed, all trees currently present in our path are stored in the treesInPath array. The doTreeLogic method also removes the trees from display and into the pool once they go out of our view using the code shown below.

var treesToRemove=[];
treesInPath.forEach( function ( element, index ) {
	oneTree=treesInPath[ index ];
	treePos.setFromMatrixPosition( oneTree.matrixWorld );
	if(treePos.z>6 &&oneTree.visible){//gone out of our view zone
		treesToRemove.push(oneTree);
	}
});
var fromWhere;
treesToRemove.forEach( function ( element, index ) {
	oneTree=treesToRemove[ index ];
	fromWhere=treesInPath.indexOf(oneTree);
	treesInPath.splice(fromWhere,1);
	treesPool.push(oneTree);
	oneTree.visible=false;
	console.log("remove tree");
});

Conclusion

Creating a 3D game is a complicated process if you are not using a visual tool like Unity. It could seem intimidating or overwhelming, but let me assure you that once you get the hang of it, you’ll feel a lot more powerful and creative. I would like you to explore further using the various physics frameworks or particle systems or the official examples.