How To Build An Endless Runner Game In Virtual Reality (Part 2)
In Part 1 of this series, we’ve seen how a virtual reality model with lighting and animation effects can be created. In this part, we will implement the game’s core logic and utilize more advanced A-Frame environment manipulations to build the “game” part of this application. By the end, you will have a functioning virtual reality game with a real challenge.
This tutorial involves a number of steps, including (but not limited to) collision detection and more A-Frame concepts such as mixins.
Prerequisites
Just like in the previous tutorial, you will need the following:
- Internet access (specifically to glitch.com);
- A Glitch project completed from part 1. (You can continue from the finished product by navigating to https://glitch.com/edit/#!/ergo-1 and clicking “Remix to edit”;
- A virtual reality headset (optional, recommended). (I use Google Cardboard, which is offered at $15 a piece.)
Step 1: Designing The Obstacles
In this step, you design the trees that we will use as obstacles. Then, you will add a simple animation that moves the trees towards the player, like the following:
These trees will serve as templates for obstacles you generate during the game. For the final part of this step, we will then remove these “template trees”.
To start, add a number of different A-Frame mixins. Mixins are commonly-used sets of component properties. In our case, all of our trees will have the same color, height, width, depth etc. In other words, all your trees will look the same and therefore will use a few shared mixins.
Note: In our tutorial, your only assets will be mixins. Visit the A-Frame Mixins page to learn more.
In your editor, navigate to index.html. Right after your sky and before your lights, add a new A-Frame entity to hold your assets:
<a-sky...></a-sky>
<!-- Mixins -->
<a-assets>
</a-assets>
<!-- Lights -->
...
In your new a-assets
entity, start by adding a mixin for your foliage. This mixins defines common properties for the foliage of the template tree. In short, it is a white, flat-shaded pyramid, for a low poly effect.
<a-assets>
<a-mixin id="foliage" geometry="
primitive: cone;
segments-height: 1;
segments-radial:4;
radius-bottom:0.3;"
material="color:white;flat-shading: true;"></a-mixin>
</a-assets>
Just below your foliage mixin, add a mixin for the trunk. This trunk will be a small, white rectangular prism.
<a-assets>
...
<a-mixin id="trunk" geometry="
primitive: box;
height:0.5;
width:0.1;
depth:0.1;"
material="color:white;"></a-mixin>
</a-assets>
Next, add the template tree objects that will use these mixins. Still in index.html, scroll down to the platforms section. Right before the player section, add a new tree section, with three empty tree entities:
<a-entity id="tree-container" ...>
<!-- Trees -->
<a-entity id="template-tree-center"></a-entity>
<a-entity id="template-tree-left"></a-entity>
<a-entity id="template-tree-right"></a-entity>
<!-- Player -->
...
Next, reposition, rescale, and add shadows to the tree entities.
<!-- Trees -->
<a-entity id="template-tree-center" shadow scale="0.3 0.3 0.3" position="0 0.6 0"></a-entity>
<a-entity id="template-tree-left" shadow scale="0.3 0.3 0.3" position="0 0.6 0"></a-entity>
<a-entity id="template-tree-right" shadow scale="0.3 0.3 0.3" position="0 0.6 0"></a-entity>
Now, populate the tree entities with a trunk and foliage, using the mixins we defined previously.
<!-- Trees -->
<a-entity id="template-tree-center" ...>
<a-entity mixin="foliage"></a-entity>
<a-entity mixin="trunk" position="0 -0.5 0"></a-entity>
</a-entity>
<a-entity id="template-tree-left" ...>
<a-entity mixin="foliage"></a-entity>
<a-entity mixin="trunk" position="0 -0.5 0"></a-entity>
</a-entity>
<a-entity id="template-tree-right" ...>
<a-entity mixin="foliage"></a-entity>
<a-entity mixin="trunk" position="0 -0.5 0"></a-entity>
</a-entity>
Navigate to your preview, and you should now see the following template trees.
Now, animate the trees from a distant location on the platform towards the user. As before, use the a-animation
tag:
<!-- Trees -->
<a-entity id="template-tree-center" ...>
...
<a-animation attribute="position" ease="linear" from="0 0.6 -7" to="0 0.6 1.5" dur="5000"></a-animation>
</a-entity>
<a-entity id="template-tree-left" ...>
...
<a-animation attribute="position" ease="linear" from="-0.5 0.55 -7" to="-0.5 0.55 1.5" dur="5000"></a-animation>
</a-entity>
<a-entity id="template-tree-right" ...>
...
<a-animation attribute="position" ease="linear" from="0.5 0.55 -7" to="0.5 0.55 1.5" dur="5000"></a-animation>
</a-entity>
Ensure that your code matches the following.
<a-entity id="tree-container"...>
<!-- Trees -->
<a-entity id="template-tree-center" shadow scale="0.3 0.3 0.3" position="0 0.6 0">
<a-entity mixin="foliage"></a-entity>
<a-entity mixin="trunk" position="0 -0.5 0"></a-entity>
<a-animation attribute="position" ease="linear" from="0 0.6 -7" to="0 0.6 1.5" dur="5000"></a-animation>
</a-entity>
<a-entity id="template-tree-left" shadow scale="0.3 0.3 0.3" position="-0.5 0.55 0">
<a-entity mixin="foliage"></a-entity>
<a-entity mixin="trunk" position="0 -0.5 0"></a-entity>
<a-animation attribute="position" ease="linear" from="-0.5 0.55 -7" to="-0.5 0.55 1.5" dur="5000"></a-animation>
</a-entity>
<a-entity id="template-tree-right" shadow scale="0.3 0.3 0.3" position="0.5 0.55 0">
<a-entity mixin="foliage"></a-entity>
<a-entity mixin="trunk" position="0 -0.5 0"></a-entity>
<a-animation attribute="position" ease="linear" from="0.5 0.55 -7" to="0.5 0.55 1.5" dur="5000"></a-animation>
</a-entity>
<!-- Player -->
...
Navigate to your preview, and you will now see the trees moving towards you.
Navigate back to your editor. This time, select assets/ergo.js. In the game section, setup trees after the window has loaded.
/********
* GAME *
********/
...
window.onload = function() {
setupTrees();
}
Underneath the controls but before the Game section, add a new TREES
section. In this section, define a new setupTrees
function.
/************
* CONTROLS *
************/
...
/*********
* TREES *
*********/
function setupTrees() {
}
/********
* GAME *
********/
...
In the new setupTrees
function, obtain references to the template tree DOM objects, and make the references available globally.
/*********
* TREES *
*********/
var templateTreeLeft;
var templateTreeCenter;
var templateTreeRight;
function setupTrees() {
templateTreeLeft = document.getElementById('template-tree-left');
templateTreeCenter = document.getElementById('template-tree-center');
templateTreeRight = document.getElementById('template-tree-right');
}
Next, define a new removeTree
utility. With this utility, you can then remove the template trees from the scene. Underneath the setupTrees
function, define your new utility.
function setupTrees() {
...
}
function removeTree(tree) {
tree.parentNode.removeChild(tree);
}
Back in setupTrees
, use the new utility to remove the template trees.
function setupTrees() {
...
removeTree(templateTreeLeft);
removeTree(templateTreeRight);
removeTree(templateTreeCenter);
}
Ensure that your tree and game sections match the following:
/*********
* TREES *
*********/
var templateTreeLeft;
var templateTreeCenter;
var templateTreeRight;
function setupTrees() {
templateTreeLeft = document.getElementById('template-tree-left');
templateTreeCenter = document.getElementById('template-tree-center');
templateTreeRight = document.getElementById('template-tree-right');
removeTree(templateTreeLeft);
removeTree(templateTreeRight);
removeTree(templateTreeCenter);
}
function removeTree(tree) {
tree.parentNode.removeChild(tree);
}
/********
* GAME *
********/
setupControls(); // TODO: AFRAME.registerComponent has to occur before window.onload?
window.onload = function() {
setupTrees();
}
Re-open your preview, and your trees should now be absent. The preview should match our game at the start of this tutorial.
This concludes the template tree design.
In this step, we covered and used A-Frame mixins, which allow us to simplify code by defining common properties. Furthermore, we leveraged A-Frame integration with the DOM to remove objects from the A-Frame VR scene.
In the next step, we will spawn multiple obstacles and design a simple algorithm to distribute trees among different lanes.
Step 2 : Spawning Obstacles
In an endless runner game, our goal is to avoid obstacles flying towards us. In this particular implementation of the game, we use three lanes as is most common.
Unlike most endless runner games, this game will only support movement left and right. This imposes a constraint on our algorithm for spawning obstacles: we can’t have three obstacles in all three lanes, at the same time, flying towards us. If that occurs, the player would have zero chance of survival. As a result, our spawning algorithm needs to accommodate this constraint.
In this step, all of our code edits will be made in assets/ergo.js. The HTML file will remain the same. Navigate to the TREES
section of assets/ergo.js.
To start, we will add utilities to spawn trees. Every tree will need a unique ID, which we will naively define to be the number of trees that exist when the tree is spawned. Start by tracking the number of trees in a global variable.
/*********
* TREES *
*********/
...
var numberOfTrees = 0;
function setupTrees() {
...
Next, we will initialize a reference to the tree container DOM element, which our spawn function will add trees to. Still in the TREES
section, add a global variable and then make the reference.
...
var treeContainer;
var numberOfTrees ...
function setupTrees() {
...
templateTreeRight = ...
treeContainer = document.getElementById('tree-container');
removeTree(...);
...
}
Using both the number of trees and the tree container, write a new function that spawns trees.
function removeTree(tree) {
...
}
function addTree(el) {
numberOfTrees += 1;
el.id = 'tree-' + numberOfTrees;
treeContainer.appendChild(el);
}
...
For ease-of-use later on, you will create a second function that adds the correct tree to the correct lane. To start, define a new templates
array in the TREES
section.
var templates;
var treeContainer;
...
function setupTrees() {
...
templates = [templateTreeLeft, templateTreeCenter, templateTreeRight];
removeTree(...);
...
}
Using this templates array, add a utility that spawns trees in a specific lane, given an ID representing left, middle, or right.
function function addTree(el) {
...
}
function addTreeTo(position_index) {
var template = templates[position_index];
addTree(template.cloneNode(true));
}
Navigate to your preview, and open your developer console. In your developer console, invoke the global addTreeTo
function.
> addTreeTo(0); # spawns tree in left lane
Now, you will write an algorithm that spawns trees randomly:
- Pick a lane randomly (that hasn’t been picked yet, for this timestep);
- Spawn a tree with some probability;
- If the maximum number of trees has been spawned for this timestep, stop. Otherwise, repeat step 1.
To effect this algorithm, we will instead shuffle the list of templates and process one at a time. Start by defining a new function, addTreesRandomly
that accepts a number of different keyword arguments.
function addTreeTo(position_index) {
...
}
/**
* Add any number of trees across different lanes, randomly.
**/
function addTreesRandomly(
{
probTreeLeft = 0.5,
probTreeCenter = 0.5,
probTreeRight = 0.5,
maxNumberTrees = 2
} = {}) {
}
In your new addTreesRandomly
function, define a list of template trees, and shuffle the list.
function addTreesRandomly( ... ) {
var trees = [
{probability: probTreeLeft, position_index: 0},
{probability: probTreeCenter, position_index: 1},
{probability: probTreeRight, position_index: 2},
]
shuffle(trees);
}
Scroll down to the bottom of the file, and create a new utilities section, along with a new shuffle
utility. This utility will shuffle an array in place.
/********
* GAME *
********/
...
/*************
* UTILITIES *
*************/
/**
* Shuffles array in place.
* @param {Array} a items An array containing the items.
*/
function shuffle(a) {
var j, x, i;
for (i = a.length - 1; i > 0; i--) {
j = Math.floor(Math.random() * (i + 1));
x = a[i];
a[i] = a[j];
a[j] = x;
}
return a;
}
Navigate back to the addTreesRandomly
function in your Trees section. Add a new variable numberOfTreesAdded
and iterate through the list of trees defined above.
function addTreesRandomly( ... ) {
...
var numberOfTreesAdded = 0;
trees.forEach(function (tree) {
});
}
In the iteration over trees, spawn a tree only with some probability and only if the number of trees added does not exceed 2
. Update the for loop as follows.
function addTreesRandomly( ... ) {
...
trees.forEach(function (tree) {
if (Math.random() < tree.probability && numberOfTreesAdded < maxNumberTrees) {
addTreeTo(tree.position_index);
numberOfTreesAdded += 1;
}
});
}
To conclude the function, return the number of trees added.
function addTreesRandomly( ... ) {
...
return numberOfTreesAdded;
}
Double check that your addTreesRandomly
function matches the following.
/**
* Add any number of trees across different lanes, randomly.
**/
function addTreesRandomly(
{
probTreeLeft = 0.5,
probTreeCenter = 0.5,
probTreeRight = 0.5,
maxNumberTrees = 2
} = {}) {
var trees = [
{probability: probTreeLeft, position_index: 0},
{probability: probTreeCenter, position_index: 1},
{probability: probTreeRight, position_index: 2},
]
shuffle(trees);
var numberOfTreesAdded = 0;
trees.forEach(function (tree) {
if (Math.random() < tree.probability && numberOfTreesAdded < maxNumberTrees) {
addTreeTo(tree.position_index);
numberOfTreesAdded += 1;
}
});
return numberOfTreesAdded;
}
Finally, to spawn trees automatically, setup a timer that runs triggers tree-spawning at regular intervals. Define the timer globally, and add a new teardown function for this timer.
/*********
* TREES *
*********/
...
var treeTimer;
function setupTrees() {
...
}
function teardownTrees() {
clearInterval(treeTimer);
}
Next, define a new function that initializes the timer and saves the timer in the previously-defined global variable. The below timer is run every half a second.
function addTreesRandomlyLoop({intervalLength = 500} = {}) {
treeTimer = setInterval(addTreesRandomly, intervalLength);
}
Finally, start the timer after the window has loaded, from the Game section.
/********
* GAME *
********/
...
window.onload = function() {
...
addTreesRandomlyLoop();
}
Navigate to your preview, and you’ll see trees spawning at random. Note that there are never three trees at once.
This concludes the obstacles step. We’ve successfully taken a number of template trees and generated an infinite number of obstacles from the templates. Our spawning algorithm also respects natural constraints in the game to make it playable.
In the next step, let’s add collision testing.
Step 3: Collision Testing
In this section, we’ll implement the collision tests between the obstacles and the player. These collision tests are simpler than collision tests in most other games; however, the player only moves along the x-axis, so whenever a tree crosses the x-axis, check if the tree’s lane is the same as the player’s lane. We will implement this simple check for this game.
Navigate to index.html, down to the TREES
section. Here, we will add lane information to each of the trees. For each of the trees, add data-tree-position-index=
, as follows. Additionally add class="tree"
, so that we can easily select all trees down the line:
<a-entity data-tree-position-index="1" class="tree" id="template-tree-center" ...>
</a-entity>
<a-entity data-tree-position-index="0" class="tree" id="template-tree-left" ...>
</a-entity>
<a-entity data-tree-position-index="2" class="tree" id="template-tree-right" ...>
</a-entity>
Navigate to assets/ergo.js and invoke a new setupCollisions
function in the GAME
section. Additionally, define a new isGameRunning
global variable that denotes whether or not an existing game is already running.
/********
* GAME *
********/
var isGameRunning = false;
setupControls();
setupCollision();
window.onload = function() {
...
Define a new COLLISIONS
section right after the TREES
section but before the Game section. In this section, define the setupCollisions function.
/*********
* TREES *
*********/
...
/**************
* COLLISIONS *
**************/
const POSITION_Z_OUT_OF_SIGHT = 1;
const POSITION_Z_LINE_START = 0.6;
const POSITION_Z_LINE_END = 0.7;
function setupCollision() {
}
/********
* GAME *
********/
As before, we will register an AFRAME component and use the tick
event listener to run code at every timestep. In this case, we will register a component with player
and run checks against all trees in that listener:
function setupCollisions() {
AFRAME.registerComponent('player', {
tick: function() {
document.querySelectorAll('.tree').forEach(function(tree) {
}
}
}
}
In the for
loop, start by obtaining the tree’s relevant information:
document.querySelectorAll('.tree').forEach(function(tree) {
position = tree.getAttribute('position');
tree_position_index = tree.getAttribute('data-tree-position-index');
tree_id = tree.getAttribute('id');
}
Next, still within the for
loop, remove the tree if it is out of sight, right after extracting the tree’s properties:
document.querySelectorAll('.tree').forEach(function(tree) {
...
if (position.z > POSITION_Z_OUT_OF_SIGHT) {
removeTree(tree);
}
}
Next, if there is no game running, do not check if there is a collision.
document.querySelectorAll('.tree').forEach(function(tree) {
if (!isGameRunning) return;
}
Finally (still in the for
loop), check if the tree shares the same position at the same time with the player. If so, call a yet-to-be-defined gameOver
function:
document.querySelectorAll('.tree').forEach(function(tree) {
...
if (POSITION_Z_LINE_START < position.z && position.z < POSITION_Z_LINE_END
&& tree_position_index == player_position_index) {
gameOver();
}
}
Check that your setupCollisions
function matches the following:
function setupCollisions() {
AFRAME.registerComponent('player', {
tick: function() {
document.querySelectorAll('.tree').forEach(function(tree) {
position = tree.getAttribute('position');
tree_position_index = tree.getAttribute('data-tree-position-index');
tree_id = tree.getAttribute('id');
if (position.z > POSITION_Z_OUT_OF_SIGHT) {
removeTree(tree);
}
if (!isGameRunning) return;
if (POSITION_Z_LINE_START < position.z && position.z < POSITION_Z_LINE_END
&& tree_position_index == player_position_index) {
gameOver();
}
})
}
})
}
This concludes the collision setup. Now, we will add a few niceties to abstract away the startGame
and gameOver
sequences. Navigate to the GAME
section. Update the window.onload
block to match the following, replacing addTreesRandomlyLoop
with a yet-to-be-defined startGame
function.
window.onload = function() {
setupTrees();
startGame();
}
Beneath the setup function invocations, create a new startGame
function. This function will initialize the isGameRunning
variable accordingly, and prevent redundant calls.
window.onload = function() {
...
}
function startGame() {
if (isGameRunning) return;
isGameRunning = true;
addTreesRandomlyLoop();
}
Finally, define gameOver
, which will alert a “Game Over!” message for now.
function startGame() {
...
}
function gameOver() {
isGameRunning = false;
alert('Game Over!');
teardownTrees();
}
This concludes the collision testing section of the endless runner game.
In this step, we again used A-Frame components and a number of other utilities that we added previously. We additionally re-organized and properly abstracted the game functions; we will subsequently augment these game functions to achieve a more complete game experience.
Conclusion
In part 1, we added VR-headset-friendly controls: Look left to move left, and right to move right. In this second part of the series, I’ve shown you how easy it can be to build a basic, functioning virtual reality game. We added game logic, so that the endless runner matches your expectations: run forever and have an endless series of dangerous obstacles fly at the player. Thus far, you have built a functioning game with keyboard-less support for virtual reality headsets.
Here are additional resources for different VR controls and headsets:
- A-Frame for VR Headsets
A survey of browsers and headsets that A-Frame VR supports. - A-Frame for VR Controllers
How A-Frame supports no controllers, 3DoF controllers and 6DoF controllers, in addition to other alternatives for interaction.
In the next part, we will add a few finishing touches and synchronize game states, which move us one step closer to multiplayer games.
Further Reading
- A Guide To Virtual Reality For Web Developers
- Reducing The Web’s Carbon Footprint: Optimizing Social Media Embeds
- Iconography In Design Systems: Easy Troubleshooting And Maintenance
- Creating Accessible UI Animations