Gaming

Build a Multiplayer Browser-based VR Game: A-Frame and WebVR

9 min read Namratha Subramanya on Aug 15, 2018

Advancements in technology have made Virtual Reality (VR) more immersive and affordable than ever. This immersive environment can be similar to the real world or it can be fantastical, creating an experience that is not possible in ordinary reality.

Better yet, high-quality VR devices are available at low prices these days. With a number of smartphone-compatible VR headsets such as Google Cardboard, Samsung Gear VR, Oculus Rift and HTC Vive, VR is showing to be the next big thing.

In this tutorial, we’ll take advantage of that and build a real-time, multiplayer VR game using A-Frame, PubNub, Glitch and WebVR.

The full GitHub code repository can be found here.



WebVR

WebVR is an open specification that makes it possible to experience VR in your browser. It is a JavaScript browser API that acts as an interface for the VR hardware. WebVR is cross-platform and can be used to develop, view and share VR content on any browser that supports VR. With WebVR, you can open up a browser and get into VR just by clicking a link. Working with WebVR directly requires knowledge of JavaScript and WebGL.

A-Frame

A-Frame is a virtual reality framework that is built upon the WebVR API. It uses the WebVR API to gain access to VR headset sensor data (position, orientation) to transform the camera and to render content directly to VR headsets. A-Frame is an open community project that uses the WebVR API along with HTML, CSS, JavaScript, and Three.js. A-Frame aims for highly immersive and interactive VR content with native-like performance. At the same time, A-Frame wants everyone to be able to get involved with VR content creation. A-Frame supports all major headsets with their controllers.

<script src="https://aframe.io/releases/0.8.0/aframe.min.js"></script>
A-Frame

Glitch

Glitch provides an online code editor with instant deployment and hosting of websites. The editor supports both front-end and back-end code as well as multiple files and directories. Glitch lets you remix (i.e., copy) existing projects and make them our own and instantly host and deploy changes for everyone to see. Firefox Nightly allows you to debug the VR content using debug console.

Gaming Environment

A-Frame Physics System

aframe-physics-system is middleware that initializes the physics engine and exposes A-Frame components for us to apply to entities. When we use its static-body or dynamic-body components, aframe-physics-system creates a Cannon.Body instance and attaches it to our A-Frame entities, so on every frame, it adjusts the entity’s position, rotation, etc. to match the body.

<script src="//cdn.rawgit.com/donmccurdy/aframe-physics-system/v3.2.0/dist/aframe-physics-system.min.js"></script>

Ball

<a-sphere> primitive creates a spherical shape. You can define its radius color and position. Because of aframe-physics-system, the ball can be converted into a dynamic-body with a certain mass.

<a-sphere id="green" color="#00AA00" position="0 1.325 3.4" radius="0.35" grabbable dynamic-body="mass: 17.5" track-position><a-sphere>

Bowling Lane

<a-box> creates shapes such as boxes, cubes, or walls. You can create a rectangle box and make a bowling lane out of it by placing pins and ball on top of it.

<a-box position="0 0.48 -2.49" color="#ffff00" width="4" height="14" rotation="90 0 0" static-body></a-box>

Pins

<a-cylinder> primitive is used to create tubes and curved surfaces. These cylinders can be used as bowling pins in the game. Be sure to define the radius, height, position, and mass of the cylinder.

<a-cylinder id="one" radius="0.1" height="0.6" position="0 1.27 -4.5" color="#ccc" dynamic-body="mass: 1.25"></a-cylinder>
<a-cylinder id="two" radius="0.1" height="0.6" position="-0.3 1.27 -6" color="#ccc" dynamic-body="mass: 1.25"></a-cylinder>
<a-cylinder id="three" radius="0.1" height="0.6" position="0.3 1.27 -5" color="#ccc" dynamic-body="mass: 1.25"></a-cylinder>
<a-cylinder id="four" radius="0.1" height="0.6" position="0 1.27 -5.5" color="#ccc" dynamic-body="mass: 1.25"></a-cylinder>
<a-cylinder id="five" radius="0.1" height="0.6" position="-0.5 1.27 -5.5" color="#ccc" dynamic-body="mass: 1.25"></a-cylinder>
<a-cylinder id="six" radius="0.1" height="0.6" position="0.5 1.27 -5.5" color="#ccc" dynamic-body="mass: 1.25"></a-cylinder>
<a-cylinder id="seven" radius="0.1" height="0.6" position="-0.3 1.27 -5" color="#ccc" dynamic-body="mass: 1.25"></a-cylinder>
<a-cylinder id="eight" radius="0.1" height="0.6" position="0.3 1.27 -6" color="#ccc" dynamic-body="mass: 1.25"></a-cylinder>
<a-cylinder id="nine" radius="0.1" height="0.6" position="-0.85 1.27 -6" color="#ccc" dynamic-body="mass: 1.25"></a-cylinder>
<a-cylinder id="ten" radius="0.1" height="0.6" position="0.85 1.27 -6" color="#ccc" dynamic-body="mass: 1.25"></a-cylinder>

Tracks

The ball cannot roll in the same direction every time you throw it. You can define any number of tracks for the ball to roll and this track can, in turn, define the direction. This game has 5 tracks and the movement of the ball on these tracks is controlled by 5 triangles or, let’s say, pointers on the bowling lane.

<a-triangle id="center" color="#ccc" position="0 1.02 1" rotation="-90 0 0" vertex-a="0 0.225 0" vertex-b="-0.225 -0.225 0" vertex-c="0.225 -0.225 0" static-body="mass:15" center></a-triangle>
<a-triangle id="far-left" color="#CCC" position="1 1.02 1" rotation="-90 0 0" vertex-a="0 0.225 0" vertex-b="-0.225 -0.225 0" vertex-c="0.225 -0.225 0" static-body="mass:15" far-right></a-triangle>
<a-triangle id="far-right" color="#CCC" position="-1 1.02 1" rotation="-90 0 0" vertex-a="0 0.225 0" vertex-b="-0.225 -0.225 0" vertex-c="0.225 -0.225 0" static-body="mass:15" far-left></a-triangle>
<a-triangle id="left" color="#CCC" position="0.5 1.02 1" rotation="-90 0 0" vertex-a="0 0.225 0" vertex-b="-0.225 -0.225 0" vertex-c="0.225 -0.225 0" static-body="mass:15" right></a-triangle>
<a-triangle id="right" color="#CCC" position="-0.5 1.02 1" rotation="-90 0 0" vertex-a="0 0.225 0" vertex-b="-0.225 -0.225 0" vertex-c="0.225 -0.225 0" static-body="mass:15" left></a-triangle>

Surroundings

A scene is represented by the <a-scene> element. The scene is the global root object, and all entities are contained within the scene. The objects’ friction, restitution, and iterations are set to values of 0.001, 0.3 and 30 respectively.

A-Frame has an asset management system that allows us to place our assets in one place and to preload and cache assets for better performance. We place such assets within <a-assets>.

<a-assets>
  <img id="floor" src="https://cdn.glitch.com/a385d449-4e52-40a5-b469-63598fd25f17%2Ffloor-1024.jpg?1532125960968">
</a-assets>
<a-plane src="#floor" rotation="-90 0 0" scale="10000 10000 1" repeat="1000 1000" static-body></a-plane>

The scale component defines a shrinking, stretching, or skewing transformation of an entity. You can use the scale component to transform a box into a wall behind the bowling lane.

<a-box position="0 4 -11" scale="24 8 1" color="#0099cc" static-body="mass:15"></a-box>

On similar lines, a box can be converted into a button attached to the wall by using the scale component. <a-text> can add text into your virtual environment.

<a-box id="refresh" position="0 7 -10.9" scale="4 1 1" color="#ccc" static-body="mass:1" refresh></a-box>
<a-text position="-1.6 7 -10.3" value="NEW GAME" color="black" wrap-count="15"></a-text>

<a-box> can also be used to build borders next to the bowling lane.

<a-box position="-3.3 0.48 -3.49" color="#666666" width="0.4" height="16" rotation="90 0 0" static-body></a-box>
<a-box position="3.3 0.48 -3.49" color="#666666" width="0.4" height="16" rotation="90 0 0" static-body></a-box>

The Game

Rolling the Ball

As discussed earlier, the ball can roll over 5 imaginary tracks on the bowling lane. This can be achieved using <a-animation>. Animations can be attached in A-Frame through <a-animation> element by making it as a child of the entity to animate.

function farLeft() {
  const slide = document.createElement('a-animation');
  slide.setAttribute('id', 'animate');
  slide.setAttribute('to', '-0.8 1.325 -9.55');
  slide.setAttribute('from', '-0.8 1.325 3');
  slide.setAttribute('attribute', 'position');
  slide.setAttribute('dur', '3000');
  document.querySelector('#green').appendChild(slide);
}
function center() {
  const slide = document.createElement('a-animation');
  slide.setAttribute('id', 'animate');
  slide.setAttribute('to', '0 1.325 -9.55');
  slide.setAttribute('from', '0 1.325 3');
  slide.setAttribute('attribute', 'position');
  slide.setAttribute('dur', '3000');
  document.querySelector('#green').appendChild(slide);
}
function left() {
  const slide = document.createElement('a-animation');
  slide.setAttribute('id', 'animate');
  slide.setAttribute('to', '-0.4 1.325 -9.55');
  slide.setAttribute('from', '-0.4 1.325 3');
  slide.setAttribute('attribute', 'position');
  slide.setAttribute('dur', '3000');
  document.querySelector('#green').appendChild(slide);
}
function right() {
  const slide = document.createElement('a-animation');
  slide.setAttribute('id', 'animate');
  slide.setAttribute('to', '0.4 1.325 -9.55');
  slide.setAttribute('from', '0.4 1.325 3');
  slide.setAttribute('attribute', 'position');
  slide.setAttribute('dur', '3000');
  document.querySelector('#green').appendChild(slide);
}
function farRight(){
  const slide = document.createElement('a-animation');
  slide.setAttribute('id', 'animate');
  slide.setAttribute('to', '0.8 1.325 -9.55');
  slide.setAttribute('from', '0.8 1.325 3');
  slide.setAttribute('attribute', 'position');
  slide.setAttribute('dur', '3000');
  document.querySelector('#green').appendChild(slide);
}

Now you can bind these animations of the ball with the 5 pointers so that the animation begins every time one of the triangles is clicked. This can be achieved by writing a component. We can register the component in JavaScript and use it declaratively from the DOM. Components are configurable, reusable, and shareable.

AFRAME.registerComponent("far-right", {
  init: function() {
  this.el.addEventListener('click', function (evt) { 
  farRight();
  });
  }
})
AFRAME.registerComponent("right", {
  init: function() {
  this.el.addEventListener('click', function (evt) { 
  right();
  });
  }
})
AFRAME.registerComponent("center", {
  init: function() {
  this.el.addEventListener('click', function (evt) { 
  center();
  });
  }
})
AFRAME.registerComponent("left", {
  init: function() {
  this.el.addEventListener('click', function (evt) { 
  left();
  });
  }
})
AFRAME.registerComponent("far-left", {
  init: function() {
  this.el.addEventListener('click', function (evt) { 
  farLeft();
  });
  }
})

Falling of Pins

When a dynamic-body of mass 17.5 rolls towards 10 dynamic bodies of mass 1.25, some of them tend to fall. After every knockdown, one can count the number of pins that are down. We can check the position of the pins at the end of the animation. If any of the pin’s rotation has its x-value not equal to 0 or -0 then it means that the pin isn’t standing upright. By counting the number of pins that are lying down, you can calculate the score of the player.

document.querySelector("#pinID").getAttribute('rotation').x;

The above line captures the x-value of the rotation attribute of a pin. This way you can fetch x-value of rotation attribute of all the pins and save it into an array. Now you can loop through the array and check every value and increment the strike counter.

for(var i = 0; i < 10; i++) {
  if(Math.trunc(down[i]) != 0 || -0){
  strike++;
  }
}

New Game

The player can start a new game at any point of time by clicking on the New Game button on the wall. It automatically refreshes the game.

AFRAME.registerComponent("refresh", {
  init: function() {
  this.el.addEventListener('click', function (evt) {
  window.location.reload();
  });
  }
})

Moving Camera

You can move the camera at any point during the game. Here, I have chosen to move the camera every time the player rolls the ball for a better view of the falling pins.

AFRAME.registerComponent('move-rig', {
  init: function () {
  document.querySelector("#center").addEventListener('click', this.moveRig.bind(this));
  document.querySelector("#left").addEventListener('click', this.moveRig.bind(this));
  document.querySelector("#far-left").addEventListener('click', this.moveRig.bind(this));
  document.querySelector("#right").addEventListener('click', this.moveRig.bind(this));
  document.querySelector("#far-right").addEventListener('click', this.moveRig.bind(this));
  },
  moveRig: function () {
  var camPos = document.querySelector("#wrapper");
  var position = camPos.getAttribute("position");
  camPos.setAttribute("position",'0 2 6'); 
  }
});

PubNub

With less than 1/4th of a second latency, PubNub can smoothly publish and subscribe messages between multiple VR devices. Let’s convert this single-player game into a 2-player game.

You’ll now have to initialize your PubNub keys. Sign up for a PubNub account and create a project in the Admin Dashboard.

var pubnub = new PubNub({
  publish_key: 'Enter your publish key here',
  subscribe_key: 'Enter your subscribe key here'
});

Deciding the Turns

Every player gets two turns. The player switch turns after every two shots. So after every two shots, PubNub can notify the other user that they can take control. In this game, every time the player gets his/her turn the 5 triangle pointers surface on the bowling lane. And when it’s not their turn, the 5 triangle pointers are hidden.

pubnub.publish({
  message: {'key' : 'your-turn'},
  channel: 'VR'
});

Hide the pointers when it’s not your turn. Here, instead of hiding, I am setting the position to 0.

const leftTriangle = document.querySelector('#left');
leftTriangle.setAttribute('position','0 0 0');
const rightTriangle = document.querySelector('#right');
rightTriangle.setAttribute('position','0 0 0');
const centerTriangle = document.querySelector('#center');
centerTriangle.setAttribute('position','0 0 0');
const farLeftTriangle = document.querySelector('#far-left');
farLeftTriangle.setAttribute('position','0 0 0');
const farRightTriangle = document.querySelector('#far-right');
farRightTriangle.setAttribute('position','0 0 0');

Make the pointer surface back to the bowling lane when it is your turn. By doing this, you’ll be taking control of the tracks again.

const leftTriangle = document.querySelector('#left');
leftTriangle.setAttribute('position','0.5 1.02 1');
const rightTriangle = document.querySelector('#right');
rightTriangle.setAttribute('position','-0.5 1.02 1');
const centerTriangle = document.querySelector('#center');
centerTriangle.setAttribute('position','0 1.02 1');
const farLeftTriangle = document.querySelector('#far-left');
farLeftTriangle.setAttribute('position','1 1.02 1');
const farRightTriangle = document.querySelector('#far-right');
farRightTriangle.setAttribute('position','-1 1.02 1');

Replicating the State of Pins After Knockdown

After every knockdown, you can capture the position of pins that are down and send it to the other user using PubNub. By doing so, you can replicate one player’s screen on other players’ screens. In the code below, you can see that the position and rotation values of pin 1 are passed to other players using PubNub. On similar lines, you can send rotation and position values of all the pins through PubNub.

for(var i = 0; i < 10; i++) {
  if(Math.trunc(down[i]) != 0 || -0){
  strike++;
  if(i == 0) {
  pubnub.publish({
  message: {
  'key' : 'pins',
  'pin' : 1,
  'rotation' : document.querySelector("#one").getAttribute('rotation'),
  'position' : document.querySelector("#one").getAttribute('position')
  },
  channel: 'VR'
  });
  }
  }
}

Switching Between Static and Dynamic Bodies

Earlier we used aframe-physics-system to convert the A-Frame objects into dynamic bodies. When the player isn’t rolling the ball and is just replicating the screen of another player, the ball should not be a dynamic body in order to avoid falling of extra pins.

AFRAME.registerComponent('track-position', {
  tick: function (time, timeDelta) {
  var currentPosition = this.el.object3D.position;
  if(dynamics == true) {
  this.el.components['dynamic-body'].syncToPhysics(); 
  }
  }
});

When it is the current player’s turn, dynamics is set to true and the dynamic-body properties are added.

function dynamicBody() {
  dynamics = true;
  document.getElementById("green").setAttribute('dynamic-body','mass:17.5');
  document.getElementById("one").setAttribute('dynamic-body','mass:1.25');
}

When it’s not the player’s turn, dynamics is set to false and the dynamic-body properties are removed.

function removeDynamicBody() {
  dynamics = false;
  document.getElementById("green").removeAttribute('dynamic-body');
  document.getElementById("one").removeAttribute('dynamic-body');
}

Player 2

Once you are done with publishing data through PubNub from Player 1’s screen, you can read the data by subscribing to PubNub’s channel.

pubnub.addListener({
  message: function(message){
    if(message.message.key == 'center') {
       center();
    } else if(message.message.key == 'far-left') {
       farLeft();
    } else if(message.message.key == 'far-right') {
       farRight();
    } else if(message.message.key == 'left') {
       left();
    } else if(message.message.key == 'right') {
       right();
    }
    if(message.message.key == 'pins') {
       // logic for replicating falling of pins
    }
    if(message.message.key == 'refresh') { 
       // code to refresh the page at the same time when the other player refreshes their screen
    }
  }
});
pubnub.subscribe({
  channels: ['VR']
});

When PubNub receives data related to the position of fallen pins’ position and rotation, you can set the attribute of pins on player 2’s screen to the same values as Player 1 and hence make the two screens identical.

if(message.message.key == 'pins') {
  if(message.message.pin == 1){
    document.querySelector("#one").setAttribute('rotation', message.message.rotation);
    document.querySelector("#one").setAttribute('position', message.message.position);
  }
}

Conclusion

Congratulations! Every time you roll the ball on Player 1’s screen you can see Player 2’s screen replicating all the movements. Now you can revert this by publishing Player 2’s data back to Player 1 and convert your game into a fully functional 2-player game. It can be converted into a multiplayer game as well. Happy VR gaming!

The full GitHub code repository can be found here.

0