How To Build An Endless Runner Game In Virtual Reality (Part 1)
How To Build An Endless Runner Game In Virtual Reality (Part 1)
Alvin Wan
Today, I’d like to invite you to build an endless runner VR game with webVR — a framework that gives a dual advantage: It can be played with or without a VR headset. I’ll explain the magic behind the gaze-based controls for our VR-headset players by removing the game control’s dependence on a keyboard.
In this tutorial, I’ll also show you how you can synchronize the game state between two devices which will move you one step closer to building a multiplayer game. I’ll specifically introduce more A-Frame VR concepts such as stylized low-poly entities, lights, and animation.
To get started, you will need the following:
- Internet access (specifically to glitch.com);
- A new Glitch project;
- A virtual reality headset (optional, recommended). (I use Google Cardboard, which is offered at $ 15 a piece.)
Note: A demo of the final product can be viewed here.
Step 1: Setting Up A Basic Scene
In this step, we will set up the following scene for our game. It is composed of a few basic geometric shapes and includes custom lighting, which we will describe in more detail below. As you progress in the tutorial, you will add various animations and effects to transform these basic geometric entities into icebergs sitting in an ocean.
You will start by setting up a website with a single static HTML page. This allows you to code from your desktop and automatically deploy to the web. The deployed website can then be loaded on your mobile phone and placed inside a VR headset. Alternatively, the deployed website can be loaded by a standalone VR headset.
Get started by navigating to glitch.com. Then, do the following:
- Click on “New Project” in the top right.
- Click on “hello-webpage” in the drop down.
- Next, click on
index.html
in the left sidebar. We will refer to this as your “editor”.
Start by deleting all existing code in the current index.html file. Then, type in the following for a basic webVR project, using A-Frame VR. This creates an empty scene by using A-Frame’s default lighting and camera.
<!DOCTYPE html> <html> <head> <title>Ergo | Endless Runner Game in Virtual Reality</title> <script src="https://aframe.io/releases/0.7.0/aframe.min.js"></script> </head> <body> <a-scene> </a-scene> </body> </html>
Note: You can learn more about A-Frame VR at aframe.io.
To start, add a fog, which will obscure objects far away for us. Modify the a-scene
tag on line 8.
<a-scene fog="type: linear; color: #a3d0ed; near:5; far:20">
Moving forward, all objects in the scene will be added between the <a-scene>...</a-scene>
tags. The first item is the sky. Between your a-scene
tags, add the a-sky
entity.
<a-scene ...> <a-sky color="#a3d0ed"></a-sky> </a-scene>
After your sky, add lighting to replace the default A-Frame lighting.
There are three types of lighting:
- Ambient
This is an ever-present light that appears to emanate from all objects in the scene. If you wanted a blue tint on all objects, resulting in blue-ish shadows, you would add a blue ambient light. For example, the objects in this Low Poly Island scene are all white. However, a blue ambient light results in a blue hue. - Directional
This is analogous to a flashlight which, as the name suggests, points in a certain direction. - Point
Again, as the name suggests, this emanates light from a point.
Just below your a-sky
entity, add the following lights: one directional and one ambient. Both are light blue.
<!-- Lights --> <a-light type="directional" castShadow="true" intensity="0.4" color="#D0EAF9;" position="5 3 1"></a-light> <a-light intensity="0.8" type="ambient" color="#B4C5EC"></a-light>
Next, add a camera with a custom position to replace the default A-Frame camera. Just below your a-light
entities, add the following:
<!-- Camera --> <a-camera position="0 0 2.5"></a-camera>
Just below your a-camera
entity, add several icebergs using low-poly cones.
<!-- Icebergs --> <a-cone class="iceberg" segments-radial="5" segments-height="3" height="1" radius-top="0.15" radius-bottom="0.5" position="3 -0.1 -1.5"></a-cone> <a-cone class="iceberg" segments-radial="7" segments-height="3" height="0.5" radius-top="0.25" radius-bottom="0.35" position="-3 -0.1 -0.5"></a-cone> <a-cone class="iceberg" segments-radial="6" segments-height="2" height="0.5" radius-top="0.25" radius-bottom="0.25" position="-5 -0.2 -3.5"></a-cone>
Next, add an ocean, which we will temporarily represent with a box, among your icebergs. In your code, add the following after the cones from above.
<!-- Ocean --> <a-box depth="50" width="50" height="1" color="#7AD2F7" position="0 -0.5 0"></a-box>
Next, add a platform for our endless runner game to take place on. We will represent this platform using the side of a large cone. After the box above, add the following:
<!-- Platform --> <a-cone scale="2 2 2" shadow position="0 -3.5 -1.5" rotation="90 0 0" radius-top="1.9" radius-bottom="1.9" segments-radial="20" segments-height="20" height="20" emissive="#005DED" emissive-intensity="0.1"> <a-entity id="tree-container" position="0 .5 -1.5" rotation="-90 0 0"> </a-entity> </a-cone>
Finally, add the player, which we will represent using a small glowing sphere, on the platform we just created. Between the <a-entity id="tree-container" ...></a-entity>
tags, add the following:
<a-entity id="tree-container"...> <!-- Player --> <a-entity id="player" player> <a-sphere radius="0.05"> <a-light type="point" intensity="0.35" color="#FF440C"></a-light> </a-sphere> </a-entity> </a-entity>
Check that your code now matches the following, exactly. You can also view the full source code for step 1.
<!DOCTYPE html> <html> <head> <title>Ergo | Endless Runner Game in Virtual Reality</title> <script src="https://aframe.io/releases/0.7.0/aframe.min.js"></script> </head> <body> <a-scene fog="type: linear; color: #a3d0ed; near:5; far:20"> <a-sky color="#a3d0ed"></a-sky> <!-- Lights --> <a-light type="directional" castShadow="true" intensity="0.4" color="#D0EAF9;" position="5 3 1"></a-light> <a-light intensity="0.8" type="ambient" color="#B4C5EC"></a-light> <!-- Camera --> <a-camera position="0 0 2.5"></a-camera> <!-- Icebergs --> <a-cone class="iceberg" segments-radial="5" segments-height="3" height="1" radius-top="0.15" radius-bottom="0.5" position="3 -0.1 -1.5"></a-cone> <a-cone class="iceberg" segments-radial="7" segments-height="3" height="0.5" radius-top="0.25" radius-bottom="0.35" position="-3 -0.1 -0.5"></a-cone> <a-cone class="iceberg" segments-radial="6" segments-height="2" height="0.5" radius-top="0.25" radius-bottom="0.25" position="-5 -0.2 -3.5"></a-cone> <!-- Ocean --> <a-box depth="50" width="50" height="1" color="#7AD2F7" position="0 -0.5 0"></a-box> <!-- Platform --> <a-cone scale="2 2 2" shadow position="0 -3.5 -1.5" rotation="90 0 0" radius-top="1.9" radius-bottom="1.9" segments-radial="20" segments-height="20" height="20" emissive="#005DED" emissive-intensity="0.1"> <a-entity id="tree-container" position="0 .5 -1.5" rotation="-90 0 0"> <!-- Player --> <a-entity id="player" player> <a-sphere radius="0.05"> <a-light type="point" intensity="0.35" color="#FF440C"></a-light> </a-sphere> </a-entity> </a-entity> </a-cone> </a-scene> </body> </html>
To preview the webpage, click on “Preview” in the top left. We will refer to this as your preview. Note that any changes in your editor will be automatically reflected in this preview, barring bugs or unsupported browsers.
In your preview, you will see the following basic virtual reality scene. You can view this scene by using your favorite VR headset.
This concludes the first step, setting up the game scene’s basic geometric objects. In the next step, you will add animations and use other A-Frame VR libraries for more visual effects.
Step 2: Improve Aesthetics for Virtual Reality Scene
In this step, you will add a number of aesthetic improvements to the scene:
- Low-poly objects
You will substitute some of the basic geometric objects with their low-poly equivalents for more convincing, irregular geometric shapes. - Animations
You will have the player bob up and down, move the icebergs slightly, and make the ocean a moving body of water.
Your final product for this step will match the following:
To start, import A-Frame low-poly components. In <head>...</head>
, add the following JavaScript import:
<script src="https://aframe.io...></script> <script src="https://cdn.jsdelivr.net/gh/alvinwan/aframe-low-poly@0.0.2/dist/aframe-low-poly.min.js"></script> </head>
The A-Frame low-poly library implements a number primitives, such as lp-cone
and lp-sphere
, each of which is a low-poly version of an A-Frame primitive. You can learn more about A-Frame primitives over here.
Next, navigate to the <!-- Icebergs -->
section of your code. Replace all <a-cone>
s with <lp-cone>
.
<!-- Icebergs --> <lp-cone class="iceberg" ...></lp-cone> <lp-cone class="iceberg" ...></lp-cone> <lp-cone class="iceberg" ...></lp-cone>
We will now configure the low-poly primitives. All low-poly primitive supports two attributes, which control how exaggerated the low-poly stylization is:
amplitude
This is the degree of stylization. The greater this number, the more a low-poly shape can deviate from its original geometry.amplitude-variance
This is how much stylization can vary, from vertex to vertex. The greater this number, the more variety there is in how much each vertex may deviate from its original geometry.
To get a better intuition for what these two variables mean, you can modify these two attributes in the A-Frame low-poly demo.
For the first iceberg, set amplitude-variance
to 0.25. For the second iceberg, set amplitude
to 0.12. For the last iceberg, set amplitude
to 0.1.
<!-- Icebergs --> <lp-cone class="iceberg" amplitude-variance="0.25" ...></lp-cone> <lp-cone class="iceberg" amplitude="0.12" ... ></lp-cone> <lp-cone class="iceberg" amplitude="0.1" ...></lp-cone>
To finish the icebergs, animate both position and rotation for all three icebergs. Feel free to configure these positions and rotations as desired.
The below features a sample setting:
<lp-cone class="iceberg" amplitude-variance="0.25" ...> <a-animation attribute="rotation" from="-5 0 0" to="5 0 0" repeat="indefinite" direction="alternate"></a-animation> <a-animation attribute="position" from="3 -0.2 -1.5" to="4 -0.2 -2.5" repeat="indefinite" direction="alternate" dur="12000" easing="linear"></a-animation> </lp-cone> <lp-cone class="iceberg" amplitude="0.12" ...> <a-animation attribute="rotation" from="0 0 -5" to="5 0 0" repeat="indefinite" direction="alternate" dur="1500"></a-animation> <a-animation attribute="position" from="-4 -0.2 -0.5" to="-2 -0.2 -0.5" repeat="indefinite" direction="alternate" dur="15000" easing="linear"></a-animation> </lp-cone> <lp-cone class="iceberg" amplitude="0.1" ...> <a-animation attribute="rotation" from="5 0 -5" to="5 0 0" repeat="indefinite" direction="alternate" dur="800"></a-animation> <a-animation attribute="position" from="-3 -0.2 -3.5" to="-5 -0.2 -5.5" repeat="indefinite" direction="alternate" dur="15000" easing="linear"></a-animation> </lp-cone>
Navigate to your preview, and you should see the low-poly icebergs bobbing around.
Next, update the platform and associated player. Here, upgrade the cone to a low-poly object, changing a-cone
to lp-cone
for <!-- Platform -->
. Additionally, add configurations for amplitude
.
<!-- Platform --> <lp-cone amplitude="0.05" amplitude-variance="0.05" scale="2 2 2"...> ... </lp-cone>
Next, still within the platform section, navigate to the <!-- Player -->
subsection of your code. Add the following animations for position, size, and intensity.
<!-- Player --> <a-entity id="player" ...> <a-sphere ...> <a-animation repeat="indefinite" direction="alternate" attribute="position" ease="ease-in-out" from="0 0.5 0.6" to="0 0.525 0.6"></a-animation> <a-animation repeat="indefinite" direction="alternate" attribute="radius" from="0.05" to="0.055" dur="1500"></a-animation> <a-light ...> <a-animation repeat="indefinite" direction="alternate-reverse" attribute="intensity" ease="ease-in-out" from="0.35" to="0.5"></a-animation> </a-light> </a-sphere> </a-entity>
Navigate to your preview, and you will see your player bobbing up and down, with a fluctuating light on a low-poly platform.
Next, let’s animate the ocean. Here, you can use a lightly-modified version of Don McCurdy’s ocean. The modifications allow us to configure how large and fast the ocean’s waves move.
Create a new file via the Glitch interface, by clicking on “+ New File” on the left. Name this new file assets/ocean.js. Paste the following into your new ocean.js file:
/** * Flat-shaded ocean primitive. * https://github.com/donmccurdy/aframe-extras * * Based on a Codrops tutorial: * http://tympanus.net/codrops/2016/04/26/the-aviator-animating-basic-3d-scene-threejs/ */ AFRAME.registerPrimitive('a-ocean', { defaultComponents: { ocean: {}, rotation: {x: -90, y: 0, z: 0} }, mappings: { width: 'ocean.width', depth: 'ocean.depth', density: 'ocean.density', amplitude: 'ocean.amplitude', 'amplitude-variance': 'ocean.amplitudeVariance', speed: 'ocean.speed', 'speed-variance': 'ocean.speedVariance', color: 'ocean.color', opacity: 'ocean.opacity' } }); AFRAME.registerComponent('ocean', { schema: { // Dimensions of the ocean area. width: {default: 10, min: 0}, depth: {default: 10, min: 0}, // Density of waves. density: {default: 10}, // Wave amplitude and variance. amplitude: {default: 0.1}, amplitudeVariance: {default: 0.3}, // Wave speed and variance. speed: {default: 1}, speedVariance: {default: 2}, // Material. color: {default: '#7AD2F7', type: 'color'}, opacity: {default: 0.8} }, /** * Use play() instead of init(), because component mappings – unavailable as dependencies – are * not guaranteed to have parsed when this component is initialized. * / play: function () { const el = this.el, data = this.data; let material = el.components.material; const geometry = new THREE.PlaneGeometry(data.width, data.depth, data.density, data.density); geometry.mergeVertices(); this.waves = []; for (let v, i = 0, l = geometry.vertices.length; i < l; i++) { v = geometry.vertices[i]; this.waves.push({ z: v.z, ang: Math.random() * Math.PI * 2, amp: data.amplitude + Math.random() * data.amplitudeVariance, speed: (data.speed + Math.random() * data.speedVariance) / 1000 // radians / frame }); } if (!material) { material = {}; material.material = new THREE.MeshPhongMaterial({ color: data.color, transparent: data.opacity < 1, opacity: data.opacity, shading: THREE.FlatShading, }); } this.mesh = new THREE.Mesh(geometry, material.material); el.setObject3D('mesh', this.mesh); }, remove: function () { this.el.removeObject3D('mesh'); }, tick: function (t, dt) { if (!dt) return; const verts = this.mesh.geometry.vertices; for (let v, vprops, i = 0; (v = verts[i]); i++){ vprops = this.waves[i]; v.z = vprops.z + Math.sin(vprops.ang) * vprops.amp; vprops.ang += vprops.speed * dt; } this.mesh.geometry.verticesNeedUpdate = true; } });
Navigate back to your index.html file. In the <head>
of your code, import the new JavaScript file:
<script src="https://cdn.jsdelivr.net..."></script> <script src="./assets/ocean.js"></script> </head>
Navigate to the <!-- Ocean -->
section of your code. Replace the a-box
to an a-ocean
. Just as before, we set amplitude
and amplitude-variance
of our low-poly object.
<!-- Ocean --> <a-ocean depth="50" width="50" amplitude="0" amplitude-variance="0.1" speed="1.5" speed-variance="1" opacity="1" density="50"></a-ocean> <a-ocean depth="50" width="50" opacity="0.5" amplitude="0" amplitude-variance="0.15" speed="1.5" speed-variance="1" density="50"></a-ocean>
For your final aesthetic modification, add a white round cursor to indicate where the user is pointing. Navigate to the <!-- Camera -->
.
<!-- Camera --> <a-camera ...> <a-entity id="cursor-mobile" cursor="fuse: true; fuseTimeout: 250" position="0 0 -1" geometry="primitive: ring; radiusInner: 0.02; radiusOuter: 0.03" material="color: white; shader: flat" scale="0.5 0.5 0.5" raycaster="far: 50; interval: 1000; objects: .clickable"> <a-animation begin="fusing" easing="ease-in" attribute="scale" fill="backwards" from="1 1 1" to="0.2 0.2 0.2" dur="250"></a-animation> </a-camera>
Ensure that your index.html code matches the Step 2 source code. Navigate to your preview, and you’ll find the updated ocean along with a white circle fixed to the center of your view.
This concludes your aesthetic improvements to the scene. In this section, you learned how to use and configure low-poly versions of A-Frame primitives, e.g. lp-cone
. In addition, you added a number of animations for different object attributes, such as position, rotation, and light intensity. In the next step, you will add the ability for the user to control the player — just by looking at different lanes.
Step 3: Add Virtual Reality Gaze Controls
Recall that our audience is a user wearing a virtual reality headset. As a result, your game cannot depend on keyboard input for controls. To make this game accessible, our VR controls will rely only on the user’s head rotation. Simply look to the right to move the player to the right, look to the center to move to the middle, and look to the left to move to the left. Our final product will look like the following.
Note: The demo GIF below was recorded on a desktop, with user drag as a substitute for head rotation.
Start from your index.html file. In the <head>...</head>
tag, import your new JavaScript file, assets/ergo.js
. This new JavaScript file will contain the game’s logic.
<script src=...></script> <script src="./assets/ergo.js"></script> </head>
Then, add a new lane-controls
attribute to your a-camera
object:
<!-- Camera --> <a-camera lane-controls position...> </a-camera>
Next, create your new JavaScript file using “+ New File” to the left. Use assets/ergo.js for the filename. For the remainder of this step, you will be working in this new JavaScript file. In this new file, define a new function to setup controls, and invoke it immediately. Make sure to include the comments below, as we will refer to sections of code by those names.
/************ * CONTROLS * ************/ function setupControls() { } /******** * GAME * ********/ setupControls();
Note: The setupControls function is invoked in the global scope, because A-Frame components must be registered before the <a-scene>
tag. I will explain what a component is below.
In your setupControls
function, register a new A-Frame component. A component modifies an entity in A-Frame, allowing you to add custom animations, change how an entity initializes, or respond to user input. There are many other use cases, but you will focus on the last one: responding to user input. Specifically, you will read user rotation and move the player accordingly.
In the setupControls
function, register the A-Frame component we added to the camera earlier, lane-controls
. We will add an event listener for the tick
event. This event triggers at every animation frame. In this event listener, hlog output at every tick.
function setupControls() { AFRAME.registerComponent('lane-controls', { tick: function(time, timeDelta) { console.log(time); } }); }
Navigate to your preview. Open your browser developer console by right-clicking anywhere and selecting “Inspect”. This applies to Firefox, Chrome, and Safari. Then, select “Console” from the top navigation bar. Ensure that you see timestamps flowing into the console.
Navigate back to your editor. Still in assets/ergo.js
, replace the body of setupControls
with the following. Fetch the camera rotation using this.el.object3D.rotation
, and log the lane to move the player to.
function setupControls() { AFRAME.registerComponent('lane-controls', { tick: function (time, timeDelta) { var rotation = this.el.object3D.rotation; if (rotation.y > 0.1) console.log("left"); else if (rotation.y < -0.1) console.log("right"); else console.log("middle"); } }) }
Navigate back to your preview. Again, open your developer console. Try rotating the camera slightly, and observe console output update accordingly.
Before the controls
section, add three constants representing the left, middle, and right lane x
values.
const POSITION_X_LEFT = -0.5; const POSITION_X_CENTER = 0; const POSITION_X_RIGHT = 0.5; /************ * CONTROLS * ************/ ...
At the start of the controls
section, define a new global variable representing the player position.
/************ * CONTROLS * ************/ // Position is one of 0 (left), 1 (center), or 2 (right) var player_position_index = 1; function setupControls() { ...
After the new global variable, define a new function that will move the player to each lane.
var player_position_index = 1; /** * Move player to provided index * @param {int} Lane to move player to */ function movePlayerTo(position_index) { } function setupControls() { ...
Inside this new function, start by updating the global variable. Then, define a dummy position.
function movePlayerTo(position_index) { player_position_index = position_index; var position = {x: 0, y: 0, z: 0} }
After defining the position, update it according to the function input.
function movePlayerTo(position_index) { ... if (position_index == 0) position.x = POSITION_X_LEFT; else if (position_index == 1) position.x = POSITION_X_CENTER; else position.x = POSITION_X_RIGHT; }
Finally, update the player position.
function movePlayerTo(position_index) { ... document.getElementById('player').setAttribute('position', position); }
Double-check that your function matches the following.
/** * Move player to provided index * @param {int} Lane to move player to */ function movePlayerTo(position_index) { player_position_index = position_index; var position = {x: 0, y: 0, z: 0} if (position_index == 0) position.x = POSITION_X_LEFT; else if (position_index == 1) position.x = POSITION_X_CENTER; else position.x = POSITION_X_RIGHT; document.getElementById('player').setAttribute('position', position); }
Navigate back to your preview. Open the developer console. Invoke your new movePlayerTo
function from the console to ensure that it functions.
> movePlayerTo(2) # should move to right
Navigate back to your editor. For the final step, update your setupControls
to move the player depending on camera rotation. Here, we replace the console.log
with movePlayerTo
invocations.
function setupControls() { AFRAME.registerComponent('lane-controls', { tick: function (time, timeDelta) { var rotation = this.el.object3D.rotation; if (rotation.y > 0.1) movePlayerTo(0); else if (rotation.y < -0.1) movePlayerTo(2); else movePlayerTo(1); } }) }
Ensure that your assets/ergo.js
matches the corresponding file in the Step 3 source code. Navigate back to your preview. Rotate the camera from side to side, and your player will now track the user’s rotation.
This concludes gaze controls for your virtual reality endless runner game.
In this section, we learned how to use A-Frame components and saw how to modify A-Frame entity properties. This also concludes part 1 of our endless runner game tutorial. You now have a virtual reality model equipped with aesthetic improvements like low-poly stylization and animations, in addition to a virtual-reality-headset-friendly gaze control for players to use.
Conclusion
We created a simple, interactive virtual reality model, as a start for our VR endless runner game. We covered a number of A-Frame concepts such as primitives, animations, and components — all of which are necessary for building a game on top of A-Frame VR.
Here are extra resources and next steps for working more with these technologies:
- A-Frame VR
Official documentation for A-Frame VR, covering the topics used above in more detail. - A-Frame Homepage
Examples of A-Frame projects, exhibiting different A-Frame capabilities. - Low-Poly Island
VR model using the same lighting, textures, and animations as the ones used for this endless runner game.
In the next part of this article series, I’ll show you how you can implement the game’s core logic and use more advanced A-Frame VR scene manipulations in JavaScript.
Stay tuned for next week!
Articles on Smashing Magazine — For Web Designers And Developers