How To Build A Real-Time Multiplayer Virtual Reality Game (Part 2)

How To Build A Real-Time Multiplayer Virtual Reality Game (Part 2)

How To Build A Real-Time Multiplayer Virtual Reality Game (Part 2)

Alvin Wan

In this tutorial series, we will build a web-based multiplayer virtual reality game, where players will need to collaborate to solve a puzzle. In the first part of this series, we designed the orbs featured in the game. In this part of the series, we will add game mechanics and setup communication protocols between pairs of players.

The game description here is excerpted from the first part of the series: Each pair of players is given a ring of orbs. The goal is to “turn on” all orbs, where an orb is “on” if it’s elevated and bright. An orb is “off” if it’s lower and dim. However, certain “dominant” orbs affect their neighbors: if it switches state, its neighbors also switch state. Player 2 can control even-numbered orbs, and player 1 can control odd-numbered orbs. This forces both players to collaborate to solve the puzzle.

The 8 steps in this tutorial are grouped into 3 sections:

  1. Populating User Interface (Steps 1 and 2)
  2. Add Game Mechanics (Steps 3 to 5)
  3. Setup Communication (Steps 6 to 8)

This part will conclude with a fully functioning demo online, for anyone to play. You will use A-Frame VR and several A-Frame extensions.

You can find the finished source code here.

The finished multiplayer game, synchronized across multiple clients
The finished multiplayer game, synchronized across multiple clients. (Large preview)

1. Add Visual Indicators

To start, we will add visual indicators of an orb’s ID. Insert a new a-text VR element as the first child of #container-orb0, on L36.

<a-entity id="container-orb0" ...>
    <a-text class="orb-id" opacity="0.25" rotation="0 -90 0" value="4" color="#FFF" scale="3 3 3" position="0 -2 -0.25" material="side:double"></a-text>
    ...
    <a-entity position...>
        ...
    </a-entity>
</a-entity>

An orb’s “dependencies” are the orbs it will toggle, when toggled: for example, say orb 1 has as dependencies orbs 2 and 3. This means that if orb 1 is toggled, orbs 2 and 3 will be toggled too. We will add visual indicators of dependencies, as follows, directly after .animation-position.

<a-animation class="animation-position" ... />
<a-text class="dep-right" opacity="0.25" rotation="0 -90 0" value="4" color="#FFF" scale="10 10 10" position="0 0 1" material="side:double" ></a-text>
            <a-text class="dep-left" opacity="0.25"rotation="0 -90 0" value="1" color="#FFF" scale="10 10 10" position="0 0 -3" material="side:double" ></a-text>

Verify that your code matches our source code for Step 1. Your orb should now match the following:

Orb with visual indicators for the orb’s ID and IDs of the orbs it will trigger
Orb with visual indicators for the orb’s ID and IDs of the orbs it will trigger (Large preview)

This concludes the additional visual indicators we will need. Next, we will dynamically add orbs to the VR scene, using this template orb.

2. Dynamically Add Orbs

In this step, we will add orbs according to a JSON-esque specification of a level. This allows us to easily specify and generate new levels. We will use the orb from the last step in part 1 as a template.

To start, import jQuery, as this will make DOM modifications, and thus modifications to the VR scene, easier. Directly after the A-Frame import, add the following to L8:

<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>

Specify a level using an array. The array will contain object literals that encode each orb’s “dependencies”. Inside the <head> tag, add the following level configuration, :

     <script>
var orbs = [
  {left: 1, right: 4},
  {},
  {on: true},
  {},
  {on: true}
];
    </script>

For now, each orb can only have one dependency to the “right” of it and one to the “left” of it. Immediately after declaring orbs above, add a handler that will run on page load. This handler will (1) duplicate the template orb and (2) remove the template orb, using the provided level configuration:

$(document).ready(function() {

  function populateTemplate(orb, template, i, total) {}
  
  function remove(selector) {}

  for (var i=0; i < orbs.length; i++) {
    var orb = orbs[i];
    var template = $('#template').clone();
    template = populateTemplate(orb, template, i, orbs.length);
    $('#carousel').append(template);
  }

  remove('#template');
}

function clickOrb(i) {}

Next, populate the remove function, which simply removes an item from the VR scene, given a selector. Fortunately, A-Frame observes changes to the DOM, and thus, removing the item from the DOM suffices to remove it from the VR scene. Populate the remove function as follows.

  function remove(selector) {
    var el = document.querySelector(selector);
    el.parentNode.removeChild(el);
  }

Populate the clickOrb function, which simply triggers the click action on an orb.

function clickOrb(i) {
  document.querySelector("#container-orb" + i).click();
}

Next, begin writing the populateTemplate function. In this function, begin by getting the .container. This container for the orb additionally contains the visual indicators we added in the previous step. Furthermore, we will need to modify the orb’s onclick behavior, based on its dependencies. If a left-dependency exists, modify both the visual indicator and the onclick behavior to reflect that; the same holds true for a right-dependency:

function populateTemplate(orb, template, i, total) {
    var container = template.find('.container');
    var onclick = 'document.querySelector("#light-orb' + i + '").emit("switch");';

    if (orb.left || orb.right) {
      if (orb.left) {
        onclick += 'clickOrb(' + orb.left + ');';
        container.find('.dep-left').attr('value', orb.left);
      }
      if (orb.right) {
        onclick += 'clickOrb(' + orb.right + ');';
        container.find('.dep-right').attr('value', orb.right);
      }
    } else {
      container.find('.dep-left').remove();
      container.find('.dep-right').remove();
    }
}

Still in the populateTemplate function, set the orb ID correctly in all of the orb and its container’s elements.

    container.find('.orb-id').attr('value', i);
    container.attr('id', 'container-orb' + i);
    template.find('.orb').attr('id', 'orb' + i);
    template.find('.light-orb').attr('id', 'light-orb' + i);
    template.find('.clickable').attr('data-id', i);

Still in the populateTemplate function, set the onclick behavior, set the random seed so that each orb is visually different, and finally, set the orb’s rotational position based on its ID.

    container.attr('onclick', onclick);
    container.find('lp-sphere').attr('seed', i);
    template.attr('rotation', '0 ' + (360 / total * i) + ' 0');

At the conclusion of the function, return the template with all the configurations above.

    return template;

Inside the document load handler and after removing the template with remove('#template'), turn on the orbs that were configured to be on initially.

$(document).ready(function() {
  ...
  setTimeout(function() {
    for (var i=0; i < orbs.length; i++) {
      var orb = orbs[i];
      if (orb.on) {
        document.querySelector("#container-orb" + i).click();
      }
    }
  }, 1000);
});

This concludes the Javascript modifications. Next, we will change the template’s default settings to that of an ‘off’ orb. Change the position and scale for #container-orb0 to the following:

position="8 0.5 0" scale="0.5 0.5 0.5"

Then, change intensity for #light-orb0 to 0.

intensity="0"

Verify that your source code matches our source code for Step 2.

Your VR scene should now feature 5 orbs, dynamically populated. One of the orbs should furthermore have visual indicators of dependencies, like below:

All orbs are populated dynamically, using the template orb
All orbs are populated dynamically, using the template orb (Large preview)

This concludes the first section in dynamically adding orbs. In the next section, we will spend three steps adding game mechanics. Specifically, the player will only be able to toggle specific orbs depending on the player ID.

3. Add Terminal State

In this step, we will add a terminal state. If all orbs are turned on successfully, the player sees a “victory” page. To do this, you will need to track the state of all orbs. Every time an orb is toggled on or off, we will need to update our internal state. Say that a helper function toggleOrb updates state for us. Invoke the toggleOrb function every time an orb changes state: (1) add a click listener to the onload handler and (2) add a toggleOrb(i); invocation to clickOrb. Finally, (3) define an empty toggleOrb.

$(document).ready(function() {
  ...
  $('.orb').on('click', function() {
    var id = $(this).attr('data-id')
    toggleOrb(id);
  });
});

function toggleOrb(i) {}

function clickOrb(i) {
  ...
  toggleOrb(i);
}

For simplicity, we will use our level configuration to indicate game state. Use toggleOrb to toggle the on state for the ith orb. toggleOrb can additionally trigger a terminal state if all orbs are turned on.

function toggleOrb(i) {
  orbs[i].on = !orbs[i].on;
  if (orbs.every(orb => orb.on)) console.log('Victory!');
}

Double-check that your code matches our source code for Step 3.

This concludes the “single-player” mode for the game. At this point, you have a fully functional virtual reality game. However, you will now need to write the multiplayer component and encourage collaboration via game mechanics.

4. Create Player Object

In this step, we will create an abstraction for a player with a player ID. This player ID will be assigned by the server later on.

For now, this will simply be a global variable. Directly after defining orbs, define a player ID:

var orbs = ...

var current_player_id = 1;

Double-check that your code matches our source code for Step 4. In the next step, this player ID will then be used to determine which orbs the player can control.

5. Conditionally Toggle Orbs

In this step, we will modify orb toggling behavior. Specifically, player 1 can control odd-numbered orbs and player 2 can control even-numbered orbs. First, implement this logic in both places where orbs change state:

    $('.orb').on('click', function() {
        var id = ...
        if (!allowedToToggle(id)) return false;
        ...
    }
...

function clickOrb(i) {
    if (!allowedToToggle(id)) return;
    ...
}

Second, define the allowedToToggle function, right after clickOrb. If the current player is player 1, odd-numbered ids will return a truth-y value and thus, player 1 will be allowed to control odd-numbered orbs. The reverse is true for player 2. All other players are not allowed to control the orbs.

function allowedToToggle(id) {
  if (current_player_id == 1) {
    return id % 2;
  } else if (current_player_id == 2) {
    return !(id % 2);
  }
  return false;
}

Double-check that your code matches our source code for Step 5. By default, the player is player 1. This means that you as player 1 can only control odd-numbered orbs in your preview. This concludes the section on game mechanics.

In the next section, we will facilitate communication between both players via a server.

6. Setup Server With WebSocket

In this step, you will set up a simple server to (1) keep track of player IDs and (2) relay messages. These messages will include game state, so that players can be certain each sees what the other sees.

We will refer to your previous index.html as the client-side source code. We will refer to code in this step as the server-side source code. Navigate to glitch.com, click on “new project” in the top-right, and in the dropdown, click on “hello-express”.

From the left-hand panel, select “package.json,” and add socket-io to dependencies. Your dependencies dictionary should now match the following.

  "dependencies": {
    "express": "^4.16.4",
    "socketio": "^1.0.0"
  },

From the left-hand panel, select “index.js,” and replace the contents of that file with the following minimal socket.io Hello World:

const express = require("express");
const app = express();

var http = require('http').Server(app);
var io = require('socket.io')(http);

/**
 * Run application on port 3000
 */

var port = process.env.PORT || 3000;

http.listen(port, function(){
  console.log('listening on *:', port);
});

The above sets up socket.io on port 3000 for a basic express application. Next, define two global variables, one for maintaining the list of active players and another for maintaining the smallest unassigned player ID.

/**
 * Maintain player IDs
 */

var playerIds = [];
var smallestPlayerId = 1;

Next, define the getPlayerId function, which generates a new player ID and marks the new player ID as “taken” by adding it to the playerIds array. In particular, the function simply marks smallestPlayerId and then updates smallestPlayerId by searching for the next smallest non-taken integer.

function getPlayerId() {
  var playerId = smallestPlayerId;
  playerIds.push(playerId);

  while (playerIds.includes(smallestPlayerId)) {
    smallestPlayerId++;
  }
  return playerId;
}

Define the removePlayer function, which updates smallestPlayerId accordingly and frees the provided playerId so that another player may take that ID.

function removePlayer(playerId) {
  if (playerId < smallestPlayerId) {
    smallestPlayerId = playerId;
  }
  var index = playerIds.indexOf(playerId);
  playerIds.splice(index, 1);
}

Finally, define a pair of socket event handlers that register new players and un-register disconnected players, using the above pair of methods.

/**
 * Handle socket interactions
 */

io.on('connection', function(socket) {
  socket.on('newPlayer', function() {
    socket.playerId = getPlayerId();
    console.log("new player: ", socket.playerId);
    socket.emit('playerId', socket.playerId);
  });

  socket.on('disconnect', function() {
    if (socket.playerId === undefined) return;
    console.log("disconnected player: ", socket.playerId);
    removePlayer(socket.playerId);
  });
});

Double-check that your code matches our source code for Step 6. This concludes basic player registration and de-registration. Each client can now use the server-generated player ID.

In the next step, we will modify the client to receive and use the server-emitted player ID.

7. Apply Player ID

In these next two steps, we will complete a rudimentary version of the multiplayer experience. To start, integrate the player ID assignment client-side. In particular, each client will ask the server for a player ID. Navigate back to the client-side index.html we were working within Steps 4 and before.

Import socket.io in the head at L7:

<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.1.1/socket.io.js"></script>

After the document load handler, instantiate the socket and emit a newPlayer event. In response, the server-side will generate a new player ID using the playerId event. Below, use the URL for your Glitch project preview instead of lightful.glitch.me. You are welcome to use the demo URL below, but any code changes you make will of course not be reflected.

$(document).ready(function() {
    ...
});

socket = io("https://lightful.glitch.me");
  socket.emit('newPlayer');
  socket.on('playerId', function(player_id) {
    current_player_id = player_id;
    console.log(" * You are now player", current_player_id);
  });

Verify that your code matches our source code for Step 7. Now, you can load your game on two different browsers or tabs to play two sides of a multiplayer game. Player 1 will be able to control odd-numbered orbs and player 2 will be able to control even-numbered orbs.

However, note that toggling orbs for player 1 will not affect orb state for player 2. Next, we need to synchronize game states.

8. Synchronize Game State

In this step, we will synchronize game states so that players 1 and 2 see the same orb states. If orb 1 is on for player 1, it should be on for player 2 as well. On the client-side, we will both announce and listen for orb toggles. To announce, we will simply pass the ID of the orb that is toggled.

Before both toggleOrb invocations, add the following socket.emit call.

$(document).ready(function() {
    ...
    $('.orb').on('click', function() {
        ...
        socket.emit('toggleOrb', id);
        toggleOrb(id);
    });
});
...
function clickOrb(i) {
    ...
    socket.emit('toggleOrb', i);
    toggleOrb(i);
}

Next, listen for orb toggles, and toggle the corresponding orb. Directly underneath the playerId socket event listener, add another listener for the toggleOrb event.

socket.on('toggleOrb', function(i) {
  document.querySelector("#container-orb" + i).click();
  toggleOrb(i);
});

This concludes modifications to the client-side code. Double-check that your code matches our source code for Step 8.

Server-side now needs to receive and broadcast the toggled orb ID. In the server-side index.js, add the following listener. This listener should be placed directly underneath the socket disconnect listener.

  socket.on('toggleOrb', function(i) {
    socket.broadcast.emit('toggleOrb', i);
  });

Double-check that your code matches our source code for Step 8. Now, player 1 loaded in one window and player 2 loaded in a second window will both see the same game state. With that, you have completed a multiplayer virtual reality game. The two players, furthermore, must collaborate to complete the objective. The final product will match the following.

The finished multiplayer game, synchronized across multiple clients
The finished multiplayer game, synchronized across multiple clients. (Large preview)

Conclusion

This concludes our tutorial on creating a multiplayer virtual reality game. In the process, you’ve touched on a number of topics, including 3-D modeling in A-Frame VR and real-time multiplayer experiences using WebSockets.

Building off of the concepts we’ve touched on, how would you ensure a smoother experience for the two players? This could include checking that the game state is synchronized and alerting the user if otherwise. You could also make simple visual indicators for the terminal state and player connection status.

Given the framework we’ve established and the concepts we’ve introduced, you now have the tools to answer these questions and build much more.

You can find the finished source code here.

Smashing Editorial (dm, il)

How To Build A Real-Time Multiplayer Virtual Reality Game (Part 1)

How To Build A Real-Time Multiplayer Virtual Reality Game (Part 1)

How To Build A Real-Time Multiplayer Virtual Reality Game (Part 1)

Alvin Wan

In this tutorial series, we will build a web-based multiplayer virtual reality game in which players will need to collaborate to solve a puzzle. We will use A-Frame for VR modeling, MirrorVR for cross-device real-time synchronization, and A-Frame Low Poly for low-poly aesthetics. At the end of this tutorial, you will have a fully functioning demo online that anyone can play.

Each pair of players is given a ring of orbs. The goal is to “turn on” all orbs, where an orb is “on” if it’s elevated and bright. An orb is “off” if it’s lower and dim. However, certain “dominant” orbs affect their neighbors: if it switches state, its neighbors also switch state. Only player 2 can control the dominant orbs while only player 1 can control non-dominant orbs. This forces both players to collaborate to solve the puzzle. In this first part of the tutorial, we will build the environment and add the design elements for our VR game.

The seven steps in this tutorial are grouped into three sections:

  1. Setting Up The Scene (Steps 1–2)
  2. Creating The Orbs (Steps 3–5)
  3. Making The Orbs Interactive (Steps 6–7)

This first part will conclude with a clickable orb that turns on and off (as pictured below). You will use A-Frame VR and several A-Frame extensions.

(Large preview)

Setting Up The Scene

1. Let’s Go With A Basic Scene

To get started, let’s take a look at how we can set up a simple scene with a ground:

Creating a simple scene
Creating a simple scene (Large preview)

The first three instructions below are excerpted from my previous article. 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:

  1. Click on “New Project” in the top right,
  2. Click on “hello-webpage” in the drop-down,
  3. Next, click on index.html in the left sidebar. We will refer to this as your “editor”.

You should now see the following Glitch screen with a default HTML file.

Glitch project: the index.html file
Glitch project: the index.html file (Large preview)

As with the linked tutorial above, 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>Lightful</title>
    <script src="https://aframe.io/releases/0.8.0/aframe.min.js"></script>
  </head>
  <body>
    <a-scene>
    </a-scene>
  </body>
</html>

Raise the camera to standing height. Per A-Frame VR recommendations (Github issue), wrap the camera with a new entity and move the parent entity instead of the camera directly. Between your a-scene tags on lines 8 and 9, add the following.

<!-- Camera! -->
<a-entity id="rig" position="0 3 0">
  <a-camera wasd-controls look-controls></a-camera>
</a-entity>

Next, add a large box to denote the ground, using a-box. Place this directly beneath your camera from the previous instruction.

<!-- Action! -->
<a-box shadow width="75" height="0.1" depth="75" position="0 -1 0" color="#222"></a-box>

Your index.html file should now match the following exactly. You can find the full source code here, on Github.

<html>
  <head>
    <title>Lightful</title>
    <script src="https://aframe.io/releases/0.8.0/aframe.min.js"></script>
  </head>
  <body>
    <a-scene>
      <!-- Camera! -->
      <a-entity id="rig" position="0 3 0">
        <a-camera wasd-controls look-controls></a-camera>
      </a-entity>

      <!-- Action! -->
      <a-box shadow width="75" height="0.1" depth="75" position="0 -1 0" color="#222"></a-box>
    </a-scene>
  </body>
</html>

This concludes setup. Next, we will customize lighting for a more mysterious atmosphere.

2. Add Atmosphere

In this step, we will set up the fog and custom lighting.

A preview of a simple scene with a dark mood
A preview of a simple scene with a dark mood (Large preview)

Add a fog, which will obscure objects far away for us. Modify the a-scene tag on line 8. Here, we will add a dark fog that quickly obscures the edges of the ground, giving the effect of a distant horizon.

<a-scene fog="type: linear; color: #111; near:10; far:15"></a-scene>

The dark gray #111 fades in linearly from a distance of 10 to a distance of 15. All objects more than 15 units away are completely obscured, and all objects fewer than 10 units away are completely visible. Any object in between is partially obscured.

Add one ambient light to lighten in-game objects and one-directional light to accentuate reflective surfaces you will add later. Place this directly after the a-scene tag you modified in the previous instruction.

<!-- Lights! -->
<a-light type="directional" castshadow="true" intensity="0.5" color="#FFF" position="2 5 0"></a-light>
<a-light intensity="0.1" type="ambient" position="1 1 1" color="#FFF"></a-light>

Directly beneath the lights in the previous instruction, add a dark sky. Notice the dark gray #111 matches that of the distant fog.

<a-sky color="#111"></a-sky>

This concludes basic modifications to the mood and more broadly, scene setup. Check that your code matches the source code for Step 2 on Github, exactly. Next, we will add a low-poly orb and begin customizing the orb’s aesthetics.

Creating The Orbs

3. Create A Low-Poly Orb

In this step, we will create a rotating, reflective orb as pictured below. The orb is composed of two stylized low-poly spheres with a few tricks to suggest reflective material.

Rotating, reflective orb
(Large preview)

Start by importing the low-poly library in your head tag. Insert the following between lines 4 and 5.

<script src="https://cdn.jsdelivr.net/gh/alvinwan/aframe-low-poly@0.0.5/dist/aframe-low-poly.min.js"></script>

Create a carousel, wrapper, and orb container. The carousel will contain multiple orbs, the wrapper will allow us to rotate all orbs around a center axis without rotating each orb individually, and the container will — as the name suggests — contain all orb components.

<a-entity id="carousel">
  <a-entity rotation="0 90 0" id="template" class="wrapper" position="0 0 0">
    <a-entity id="container-orb0" class="container" position="8 3 0" scale="1 1 1">
      <!-- place orb here -->
    </a-entity>
  </a-entity>
</a-entity>

Inside the orb container, add the orb itself: one sphere is slightly translucent and offset, and the other is completely solid. The two combined mimic reflective surfaces.

<a-entity class="orb" id="orb0" data-id="0">
  <lp-sphere seed="0" shadow max-amplitude="1 1 1" position="-0.5 0 -0.5"></lp-sphere>
  <lp-sphere seed="0" shadow max-amplitude="1 1 1" rotation="0 45 45" opacity="0.5" position="-0.5 0 -0.5"></lp-sphere>
</a-entity>

Finally, rotate the sphere indefinitely by adding the following a-animation tag immediately after the lp-sphere inside the .orb entity in the last instruction.

<a-animation attribute="rotation" repeat="indefinite" from="0 0 0" to="0 360 0" dur="5000"></a-animation>

Your source code for the orb wrappers and the orb itself should match the following exactly.

<a-entity id="carousel">
  <a-entity rotation="0 90 0" id="template" class="wrapper" position="0 0 0">
    <a-entity id="container-orb0" class="container" position="8 3 0" scale="1 1 1">
      <a-entity class="orb" id="orb0" data-id="0">
        <lp-sphere seed="0" shadow max-amplitude="1 1 1" position="-0.5 0 -0.5"></lp-sphere>
        <lp-sphere seed="0" shadow max-amplitude="1 1 1" rotation="0 45 45" opacity="0.5" position="-0.5 0 -0.5"></lp-sphere>
        <a-animation attribute="rotation" repeat="indefinite" from="0 0 0" to="0 360 0" dur="5000"></a-animation>
      </a-entity>
    </a-entity>
  </a-entity>
</a-entity>

Check that your source code matches the full source code for step 3 on Github. Your preview should now match the following.

Rotating, reflective orb
(Large preview)

Next, we will add more lighting to the orb for a golden hue.

4. Light Up The Orb

In this step, we will add two lights, one colored and one white. This produces the following effect.

Orb lit with point lights
(Large preview)

Start by adding the white light to illuminate the object from below. We will use a point light. Directly before #orb0 but within #container-orb0, add the following offset point light.

<a-entity position="-2 -1 0">
    <a-light distance="8" type="point" color="#FFF" intensity="0.8"></a-light>
</a-entity>

In your preview, you will see the following.

Orb lit with white point light
(Large preview)

By default, lights do not decay with distance. By adding distance="8", we ensure that the light fully decays with a distance of 8 units, to prevent the point light from illuminating the entire scene. Next, add the golden light. Add the following directly above the last light.

<a-light class="light-orb" id="light-orb0" distance="8" type="point" color="#f90" intensity="1"></a-light>

Check that your code matches the source code for step 4 exactly. Your preview will now match the following.

Orb lit with point lights
(Large preview)

Next, you will make your final aesthetic modification to the orb and add rotating rings.

5. Add Rings

In this step, you will produce the final orb, as pictured below.

Golden orb with multiple rings
(Large preview)

Add a ring in #container-orb0 directly before #orb0.

<a-ring color="#fff" material="side:double" position="0 0.5 0" radius-inner="1.9" radius-outer="2" opacity="0.25"></a-ring>

Notice the ring itself does not contain color, as the color will be imbued by the point light in the previous step. Furthermore, the material="side:double" is important as, without it, the ring’s backside would not be rendered; this means the ring would disappear for half of its rotation.

However, the preview with only the above code will not look any different. This is because the ring is currently perpendicular to the screen. Thus, only the ring’s “side” (which has 0 thickness) is visible. Place the following animation in between the a-ring tags in the previous instruction.

<a-animation attribute="rotation" easing="linear" repeat="indefinite" from="0 0 0" to="0 360 0" dur="8000"></a-animation>

Your preview should now match the following:

Golden orb with ring
(Large preview)

Create a variable number of rings with different rotation axes, speeds, and sizes. You can use the following example rings. Any new rings should be placed underneath the last a-ring.

<a-ring color="#fff" material="side:double" position="0 0.5 0" radius-inner="2.4" radius-outer="2.5" opacity="0.25">
  <a-animation attribute="rotation" easing="linear" repeat="indefinite" from="0 45 0" to="360 45 0" dur="8000"></a-animation>
</a-ring>
<a-ring color="#fff" material="side:double" position="0 0.5 0" radius-inner="1.4" radius-outer="1.5" opacity="0.25">
  <a-animation attribute="rotation" easing="linear" repeat="indefinite" from="0 -60 0" to="-360 -60 0" dur="3000"></a-animation>
</a-ring>

Your preview will now match the following.

Golden orb with multiple rings
(Large preview)

Check that your code matches the source code for step 5 on Github. This concludes decor for the orb. With the orb finished, we will next add interactivity to the orb. In the next step, we will specifically add a visible cursor with a clicking animation when pointed at clickable objects.

Making The Orbs Interactive

6. Add A Cursor

In this step, we will add a white cursor that can trigger clickable objects. The cursor is pictured below.

clicking on orb
(Large preview)

In your a-camera tag, add the following entity. The fuse attribute allows this entity the ability to trigger click events. The raycaster attribute determines how often and how far to check for clickable objects. The objects attribute accepts a selector to determine which entities are clickable. In this case, all objects of class clickable are clickable.

<a-entity cursor="fuse: true; fuseTimeout: 250"
      position="0 0 -1"
      geometry="primitive: ring; radiusInner: 0.03; radiusOuter: 0.04"
      material="color: white; shader: flat; opacity: 0.5"
      scale="0.5 0.5 0.5"
      raycaster="far: 20; interval: 1000; objects: .clickable">
    <!-- Place cursor animation here -->
</a-entity>

Next, add cursor animation and an extra ring for aesthetics. Place the following inside the entity cursor object above. This adds animation to the cursor object so that clicks are visible.

<a-circle radius="0.01" color="#FFF" opacity="0.5" material="shader: flat"></a-circle>
<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>

Next, add the clickable class to the #orb0 to match the following.

<a-entity class="orb clickable" id="orb0" data-id="0">

Check that your code matches the source code for Step 6 on Github. In your preview, drag your cursor off of them onto the orb to see the click animation in action. This is pictured below.

clicking on orb
(Large preview)

Note the clickable attribute was added to the orb itself and not the orb container. This is to prevent the rings from becoming clickable objects. This way, the user must click on the spheres that make up the orb itself.

In our final step for this part, you will add animation to control the on and off states for the orb.

7. Add Orb States

In this step, you will animate the orb in and out of an off state on click. This is pictured below.

Interactive orb responding to clicks
(Large preview)

To start, you will shrink and lower the orb to the ground. Add a-animation tags to the #container-orb0 right after #orb0. Both animations are triggered by a click and share the same easing function ease-elastic for a slight bounce.

<a-animation class="animation-scale" easing="ease-elastic" begin="click" attribute="scale" from="0.5 0.5 0.5" to="1 1 1" direction="alternate" dur="2000"></a-animation>
<a-animation class="animation-position" easing="ease-elastic" begin="click" attribute="position" from="8 0.5 0" to="8 3 0" direction="alternate" dur="2000"></a-animation>

To further emphasize the off state, we will remove the golden point light when the orb is off. However, the orb’s lights are placed outside of the orb object. Thus, the click event is not passed to the lights when the orb is clicked. To circumvent this issue, we will use some light Javascript to pass the click event to the light. Place the following animation tag in #light-orb0. The light is triggered by a custom switch event.

<a-animation class="animation-intensity" begin="switch" attribute="intensity" from="0" to="1" direction="alternate"></a-animation>

Next, add the following click event listener to the #container-orb0. This will relay the clicks to the orb lights.

<a-entity id="container-orb0" ... onclick="document.querySelector('#light-orb0').emit('switch');">

Check that your code matches the source code for Step 7 on Github. Finally, pull up your preview, and move the cursor on and off the orb to toggle between off and on states. This is pictured below.

Interactive orb responding to clicks
(Large preview)

This concludes the orb’s interactivity. The player can now turn orbs on and off at will, with self-explanatory on and off states.

Conclusion

In this tutorial, you built a simple orb with on and off states, which can be toggled by a VR-headset-friendly cursor click. With a number of different lighting techniques and animations, you were able to distinguish between the two states. This concludes the virtual reality design elements for the orbs. In the next part of the tutorial, we will populate the orbs dynamically, add game mechanics, and set up a communication protocol between a pair of players.

Smashing Editorial (rb, dm, il)

How To Build An Endless Runner Game In Virtual Reality (Part 3)

How To Build An Endless Runner Game In Virtual Reality (Part 3)

How To Build An Endless Runner Game In Virtual Reality (Part 3)

Alvin Wan

And so our journey continues. In this final part of my series on how to build an endless runner VR game, I’ll 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 MirrorVR which is responsible for handling the mediating server in client-to-client communication.

Note: This game can be played with or without a VR headset. You can view a demo of the final product at ergo-3.glitch.me.

To get started, you will need the following.

  • Internet access (specifically to glitch.com);
  • A Glitch project completed from part 2 of this tutorial. You can start from the part 2 finished product by navigating to https://glitch.com/edit/#!/ergo-2 and clicking “Remix to edit”;
  • A virtual reality headset (optional, recommended). (I use Google Cardboard, which is offered at $15 a piece.)

Step 1: Display Score

The game as-is functions at a bare minimum, where the player is given a challenge: avoid the obstacles. However, outside of object collisions, the game does not provide feedback to the player regarding progress in the game. To remedy this, you will implement the score display in this step. The score will be large text object placed in our virtual reality world, as opposed to an interface glued to the user’s field of view.

In virtual reality generally, the user interface is best integrated into the world rather than stuck to the user’s head.

Score display
Score display (Large preview)

Start by adding the object to index.html. Add a text mixin, which will be reused for other text elements:

<a-assets>
  ...
  <a-mixin id="text" text="
     font:exo2bold;
     anchor:center;
     align:center;"></a-mixin>
  ...
</a-assets>

Next, add a text element to the platform, right before the player:

<!-- Score -->
<a-text id="score" value="" mixin="text" height="40" width="40" position="0 1.2 -3" opacity="0.75"></a-text>

<!-- Player -->
...

This adds a text entity to the virtual reality scene. The text is not currently visible, because its value is set to empty. However, you will now populate the text entity dynamically, using JavaScript. Navigate to assets/ergo.js. After the collisions section, add a score section, and define a number of global variables:

  • score: the current game score.
  • countedTrees: IDs of all trees that are included in the score. (This is because collision tests may trigger multiple times for the same tree.)
  • scoreDisplay: reference to the DOM object, corresponding to a text object in the virtual reality world.
/*********
 * SCORE *
 *********/

var score;
var countedTrees;
var scoreDisplay;

Next, define a setup function to initialize our global variables. In the same vein, define a teardown function.

...
var scoreDisplay;

function setupScore() {
  score = 0;
  countedTrees = new Set();
  scoreDisplay = document.getElementById('score');
}

function teardownScore() {
  scoreDisplay.setAttribute('value', '');
}

In the Game section, update gameOver, startGame, and window.onload to include score setup and teardown.

/********
 * GAME *
 ********/

function gameOver() {
    ...
    teardownScore();
}

function startGame() {
    ...
    setupScore();
    addTreesRandomlyLoop();
}

window.onload = function() {
    setupScore();
    ...
}

Define a function that increments the score for a particular tree. This function will check against countedTrees to ensure that the tree is not double counted.

function addScoreForTree(tree_id) {
  if (countedTrees.has(tree_id)) return;
  score += 1;
  countedTrees.add(tree_id);
}

Additionally, add a utility to update the score display using the global variable.

function updateScoreDisplay() {
  scoreDisplay.setAttribute('value', score);
}

Update the collision testing accordingly in order to invoke this score-incrementing function whenever an obstacle has passed the player. Still in assets/ergo.js, navigate to the collisions section. Add the following check and update.

AFRAME.registerComponent('player', {
  tick: function() {
    document.querySelectorAll('.tree').forEach(function(tree) {
      ...
      if (position.z > POSITION_Z_LINE_END) {
        addScoreForTree(tree_id);
        updateScoreDisplay();
      }
    })
  }
})

Finally, update the score display as soon as the game starts. Navigate to the Game section, and add updateScoreDisplay(); to startGame:

function startGame() {
  ...
  setupScore();
  updateScoreDisplay();
  ...
}

Ensure that assets/ergo.js and index.html match the corresponding source code files. Then, navigate to your preview. You should see the following:

Score display
Score display (Large preview)

This concludes the score display. Next, we will add proper start and Game Over menus, so that the player can replay the game as desired.

Step 2: Add Start Menu

Now that the user can keep track of the progress, you will add finishing touches to complete the game experience. In this step, you will add a Start menu and a Game Over menu, letting the user start and restart games.

Let’s begin with the Start menu where the player clicks a “Start” button to begin the game. For the second half of this step, you will add a Game Over menu, with a “Restart” button:

Start and game over menus
Start and game over menus (Large preview)

Navigate to index.html in your editor. Then, find the Mixins section. Here, append the title mixin, which defines styles for particularly large text. We use the same font as before, align text to the center, and define a size appropriate for the type of text. (Note below that anchor is where a text object is anchored to its position.)

<a-assets>
   ...
   <a-mixin id="title" text="
       font:exo2bold;
       height:40;
       width:40;
       opacity:0.75;
       anchor:center;
       align:center;"></a-mixin>
</a-assets>

Next, add a second mixin for secondary headings. This text is slightly smaller but is otherwise identical to the title.

<a-assets>
   ...
   <a-mixin id="heading" text="
       font:exo2bold;
       height:10;
       width:10;
       opacity:0.75;
       anchor:center;
       align:center;"></a-mixin>
</a-assets>

For the third and final mixin, define properties for descriptive text — even smaller than secondary headings.

<a-assets>
   ...
   <a-mixin id="copy" text="
       font:exo2bold;
       height:5;
       width:5;
       opacity:0.75;
       anchor:center;
       align:center;"></a-mixin>
</a-assets>

With all text styles defined, you will now define the in-world text objects. Add a new Menus section beneath the Score section, with an empty container for the Start menu:

<!-- Score -->
...

<!-- Menus -->
<a-entity id="menu-container">
  <a-entity id="start-menu" position="0 1.1 -3">
  </a-entity>
</a-entity>

Inside the start menu container, define the title and a container for all non-title text:

...
  <a-entity id="start-menu" ...>
    <a-entity id="start-copy" position="0 1 0">
    </a-entity>
    <a-text value="ERGO" mixin="title"></a-text>
  </a-entity>
</a-entity>

Inside the container for non-title text, add instructions for playing the game:

<a-entity id="start-copy"...>
  <a-text value="Turn left and right to move your player, and avoid the trees!" mixin="copy"></a-text>
</a-entity>

To complete the Start menu, add a button that reads “Start”:

<a-entity id="start-copy"...>
  ...
  <a-text value="Start" position="0 0.75 0" mixin="heading"></a-text>
  <a-box id="start-button" position="0 0.65 -0.05" width="1.5" height="0.6" depth="0.1"></a-box>
</a-entity>

Double-check that your Start menu HTML code matches the following:

<!-- Menus -->
<a-entity id="menu-container">
  <a-entity id="start-menu" position="0 1.1 -3">
    <a-entity id="start-copy" position="0 1 0">
      <a-text value="Turn left and right to move your player, and avoid the trees!" mixin="copy"></a-text>
      <a-text value="Start" position="0 0.75 0" mixin="heading"></a-text>
      <a-box id="start-button" position="0 0.65 -0.05" width="1.5" height="0.6" depth="0.1"></a-box>
    </a-entity>
    <a-text value="ERGO" mixin="title"></a-text>
  </a-entity>
</a-entity>

Navigate to your preview, and you will see the following Start menu:

Image of Start menu
Start menu (Large preview)

Still in the Menus section (directly beneath the start menu), add the game-over menu using the same mixins:

<!-- Menus -->
<a-entity id="menu-container">
  ...
  <a-entity id="game-over" position="0 1.1 -3">
    <a-text value="?" mixin="heading" id="game-score" position="0 1.7 0"></a-text>
    <a-text value="Score" mixin="copy" position="0 1.2 0"></a-text>
    <a-entity id="game-over-copy">
      <a-text value="Restart" mixin="heading" position="0 0.7 0"></a-text>
      <a-box id="restart-button" position="0 0.6 -0.05" width="2" height="0.6" depth="0.1"></a-box>
    </a-entity>
    <a-text value="Game Over" mixin="title"></a-text>
  </a-entity>
</a-entity>

Navigate to your JavaScript file, assets/ergo.js. Create a new Menus section before the Game section. Additionally, define three empty functions: setupAllMenus, hideAllMenus, and showGameOverMenu.

/********
 * MENU *
 ********/

function setupAllMenus() {
}

function hideAllMenus() {
}

function showGameOverMenu() {
}

/********
 * GAME *
 ********/

Next, update the Game section in three places. In gameOver, show the Game Over menu:

function gameOver() {
    ...
    showGameOverMenu();
}
```

In `startGame`, hide all menus:

```
function startGame() {
    ...
    hideAllMenus();
}

Next, in window.onload, remove the direct invocation to startGame and instead call setupAllMenus. Update your listener to match the following:

window.onload = function() {
  setupAllMenus();
  setupScore();
  setupTrees();
}

Navigate back to the Menu section. Save references to various DOM objects:

/********
 * MENU *
 ********/

var menuStart;
var menuGameOver;
var menuContainer;
var isGameRunning = false;
var startButton;
var restartButton;

function setupAllMenus() {
  menuStart     = document.getElementById('start-menu');
  menuGameOver  = document.getElementById('game-over');
  menuContainer = document.getElementById('menu-container');
  startButton   = document.getElementById('start-button');
  restartButton = document.getElementById('restart-button');
}

Next, bind both the “Start” and “Restart” buttons to startGame:

function setupAllMenus() {
  ...
  startButton.addEventListener('click', startGame);
  restartButton.addEventListener('click', startGame);
}

Define showStartMenu and invoke it from setupAllMenus:

function setupAllMenus() {
  ...
  showStartMenu();
}

function hideAllMenus() {
}

function showGameOverMenu() {
}

function showStartMenu() {
}

To populate the three empty functions, you will need a few helper functions. Define the following two functions, which accepts a DOM element representing an A-Frame VR entity and shows or hides it. Define both functions above showAllMenus:

...
var restartButton;

function hideEntity(el) {
  el.setAttribute('visible', false);
}

function showEntity(el) {
  el.setAttribute('visible', true);
}

function showAllMenus() {
...

First populate hideAllMenus. You will remove the objects from sight, then remove click listeners for both menus:

function hideAllMenus() {
  hideEntity(menuContainer);
  startButton.classList.remove('clickable');
  restartButton.classList.remove('clickable');
}

Second, populate showGameOverMenu. Here, restore the container for both menus, as well as the Game Over menu and the ‘Restart’ button’s click listener. However, remove the ‘Start’ button’s click listener, and hide the ‘Start’ menu.

function showGameOverMenu() {
  showEntity(menuContainer);
  hideEntity(menuStart);
  showEntity(menuGameOver);
  startButton.classList.remove('clickable');
  restartButton.classList.add('clickable');
}

Third, populate showStartMenu. Here, reverse all changes that the showGameOverMenu effected.

function showStartMenu() {
  showEntity(menuContainer);
  hideEntity(menuGameOver);
  showEntity(menuStart);
  startButton.classList.add('clickable');
  restartButton.classList.remove('clickable');
}

Double-check that your code matches the corresponding source files. Then, navigate to your preview, and you will observe the following behavior:

Start and game over menus
Start and Game Over menus (Large preview)

This concludes the Start and Game Over menus.

Congratulations! You now have a fully functioning game with a proper start and proper end. However, we have one more step left in this tutorial: We need to synchronize the game state between different player devices. This will move us one step closer towards multiplayer games.

Step 3: Synchronizing Game State With MirrorVR

In a previous tutorial, you learned how to send real-time information across sockets, to facilitate one-way communication between a server and a client. In this step, you will build on top of a fully-fledged product of that tutorial, MirrorVR, which handles the mediating server in client-to-client communication.

Note: You can learn more about MirrorVR here.

Navigate to index.html. Here, we will load MirrorVR and add a component to the camera, indicating that it should mirror a mobile device’s view where applicable. Import the socket.io dependency and MirrorVR 0.2.3.

<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.1.1/socket.io.js"></script>
<script src="https://cdn.jsdelivr.net/gh/alvinwan/mirrorvr@0.2.2/dist/mirrorvr.min.js"></script>

Next, add a component, camera-listener, to the camera:

<a-camera camera-listener ...>

Navigate to assets/ergo.js. In this step, the mobile device will send commands, and the desktop device will only mirror the mobile device.

To facilitate this, you need a utility to distinguish between desktop and mobile devices. At the end of your file, add a mobileCheck function after shuffle:

/**
 * Checks for mobile and tablet platforms.
 */
function mobileCheck() {
  var check = false;
  (function(a){if(/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino|android|ipad|playbook|silk/i.test(a)||/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0,4))) check = true;})(navigator.userAgent||navigator.vendor||window.opera);
  return check;
};

First, we will synchronize the game start. In startGame, of the Game section, add a mirrorVR notification at the end.

function startGame() {
  ...
  if (mobileCheck()) {
    mirrorVR.notify('startGame', {})
  }
}

The mobile client now sends notifications about a game starting. You will now implement the desktop’s response.

In the window load listener, invoke a setupMirrorVR function:

window.onload = function() {
  ...
  setupMirrorVR();
}

Define a new section above the Game section for the MirrorVR setup:

/************
 * MirrorVR *
 ************/

function setupMirrorVR() {
  mirrorVR.init();
}

Next, add keyword arguments to the initialization function for mirrorVR. Specifically, we will define the handler for game start notifications. We will additionally specify a room ID; this ensures that anyone loading your application is immediately synchronized.

function setupMirrorVR() {
  mirrorVR.init({
    roomId: 'ergo',
    state: {
      startGame: {
        onNotify: function(data) {
          hideAllMenus();
          setupScore();
          updateScoreDisplay();
        }
      },
    }
  });
}

Repeat the same synchronization process for Game Over. In gameOver in the Game section, add a check for mobile devices and send a notification accordingly:

function gameOver() {
  ...
  if (mobileCheck()) {
    mirrorVR.notify('gameOver', {});
  }
}

Navigate to the MirrorVR section and update the keyword arguments with a gameOver listener:

function setupMirrorVR() {
  mirrorVR.init({
    state: {
      startGame: {...
      },
      gameOver: {
        onNotify: function(data) {
          gameOver();
        }
      },
    }
  }) 
}

Next, repeat the same synchronization process for the addition of trees. Navigate to addTreesRandomly in the Trees section. Keep track of which lanes receive new trees. Then, directly before the return directive, and send a notification accordingly:

function addTreesRandomly(...) {
  ...
  var numberOfTreesAdded ...
  var position_indices = [];
  trees.forEach(function (tree) {
    if (...) {
      ...
      position_indices.push(tree.position_index);
    }
  });

  if (mobileCheck()) {
    mirrorVR.notify('addTrees', position_indices);
  }
  return ...
}

Navigate to the MirrorVR section, and update the keyword arguments to mirrorVR.init with a new listener for trees:

function setupMirrorVR() {
  mirrorVR.init({
    state: {
      ...
      gameOver: {...
      },
      addTrees: {
        onNotify: function(position_indices) {
          position_indices.forEach(addTreeTo)
        }
      },
    }
  }) 
}

Finally, we synchronize the game score. In updateScoreDisplay from the Score section, send a notification when applicable:

function updateScoreDisplay() {
  ...
  if (mobileCheck()) {
    mirrorVR.notify('score', score);
  }
}

Update the mirrorVR initialization for the last time, with a listener for score changes:

function setupMirrorVR() {
  mirrorVR.init({
    state: {
      addTrees: {
      },
      score: {
        onNotify: function(data) {
          score = data;
          updateScoreDisplay();
        }
      }
    }
  });
}

Double-check that your code matches the appropriate source code files for this step. Then, navigate to your desktop preview. Additionally, open up the same URL on your mobile device. As soon as your mobile device loads the webpage, your desktop should immediately start mirroring the mobile device’s game.

Here is a demo. Notice that the desktop cursor is not moving, indicating the mobile device is controlling the desktop preview.

Final Endless Runner Game with MirrorVR game state synchronization
Final result of the endless runner game with MirrorVR game state synchronization (Large preview)

This concludes your augmented project with mirrorVR.

This third step introduced a few basic game state synchronization steps; to make this more robust, you could add more sanity checks and more points of synchronization.

Conclusion

In this tutorial, you added finishing touches to your endless runner game and implemented real-time synchronization of a desktop client with a mobile client, effectively mirroring the mobile device’s screen on your desktop. This concludes the series on building an endless runner game in virtual reality. Along with A-Frame VR techniques, you’ve picked up 3D modeling, client-to-client communication, and other widely applicable concepts.

Next steps can include:

  • More Advanced Modeling
    This means more realistic 3D models, potentially created in a third-party software and imported. For example, (MagicaVoxel) makes creating voxel art simple, and (Blender) is a complete 3D modeling solution.
  • More Complexity
    More complex games, such as a real-time strategy game, could leverage a third-party engine for increased efficiency. This may mean sidestepping A-Frame and webVR entirely, instead publishing a compiled (Unity3d) game.

Other avenues include multiplayer support and richer graphics. With the conclusion of this tutorial series, you now have a framework to explore further.

Smashing Editorial (rb, ra, il)

How To Build An Endless Runner Game In Virtual Reality (Part 2)

How To Build An Endless Runner Game In Virtual Reality (Part 2)

How To Build An Endless Runner Game In Virtual Reality (Part 2)

Alvin Wan

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:

Template trees moving towards player
Template trees moving towards player (Large preview)

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.

Template trees for obstacles
Template trees for obstacles (Large preview)

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.

Template trees moving towards player
Template trees moving towards playerTemplate trees moving towards player (Large preview)

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.

Part 1 finished product
Part 1 finished product (Large preview)

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
Invoking addTreeTo manually
Invoke addTreeTo manually (Large preview)

Now, you will write an algorithm that spawns trees randomly:

  1. Pick a lane randomly (that hasn’t been picked yet, for this timestep);
  2. Spawn a tree with some probability;
  3. 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.

Tree spawning at random
Tree randomly spawning (Large preview)

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:

In the next part, we will add a few finishing touches and synchronize game states, which move us one step closer to multiplayer games.

Stay tuned for Part 3!

Smashing Editorial (rb, ra, yk, il)

How To Build An Endless Runner Game In Virtual Reality (Part 1)

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.

A preview of the game scene’s basic geometric objects
A preview of the game scene’s basic geometric objects (Large preview)

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:

  1. Click on “New Project” in the top right.
  2. Click on “hello-webpage” in the drop down.
    Glitch.com’s homepage
    Glitch.com’s homepage (Large preview)
  3. Next, click on index.html in the left sidebar. We will refer to this as your “editor”.
Glitch project index.html file
Glitch project: the index.html file (Large preview)

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.

“Show Live” button in glitch project
“Show Live” button in glitch project (Large preview)

In your preview, you will see the following basic virtual reality scene. You can view this scene by using your favorite VR headset.

Animating Ocean and Fixed White Cursor
Animating Ocean and the fixed white cursor (Large preview)

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:

  1. Low-poly objects
    You will substitute some of the basic geometric objects with their low-poly equivalents for more convincing, irregular geometric shapes.
  2. 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:

Low-poly icebergs bobbing around
Low-poly icebergs bobbing around (Large preview)

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:

  1. amplitude
    This is the degree of stylization. The greater this number, the more a low-poly shape can deviate from its original geometry.
  2. 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.

Bobbing player with fluctuating light
Bobbing player with fluctuating light (Large preview)

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.

Bobbing player with fluctuating light
Bobbing player with fluctuating light (Large preview)

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.

Bobbing player with fluctuating light
Bobbing player with fluctuating light (Large preview)

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.

Controlling game character with head rotation
Controlling game character with head rotation (Large preview)

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.

Timestamps in console
Timestamps in console (Large preview)

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.

Lane log based on camera rotation
Lane log based on camera rotation (Large preview)

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.

Controlling game character with head rotation
Controlling game character with head rotation (Large preview)

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!

Smashing Editorial (rb, ra, il)