Multi-user VR Office Space Using A-Frame And NAF

My “Virtual Office Space” in a virtual landscape

This article now goes along with an even more advanced article including sample code that takes advantage of the setup you do on your server in this article. So you could think of it as Part 2 to this article, which is Part 1.

Advanced A-Frame Article —really not kidding: The idea for this article started off with the need to have multi-user access to a common VR space. There are a multitude of practical applications for this which I won’t go all into, but I will choose one, “Common Virtual Office Space”.

A 3D space place for people to meet, discuss and work together on projects, ideas and things. The idea is simple, because many of us do this regularly, but the technical solution is somewhat complex.

So let’s dive right in!

Here is an example, try it out (you’ll need two people or two tabs open to test, be patient on the load, cell phones not recommended because of the heavy graphics):

Before We Continue . . .

For those who haven’t been following me, I have written a number of technical articles on A-Frame and virtual reality using a relatively new technology called WebXR. These are require to get a basic understanding of how the virtual environment is created. So unfortunately, or fortunately, however you look at it, you should probably start with these first.

So with your new found understanding, we will now move forward!

A-Frame has what I would call an auxiliary component called Networked A-Frame or NAF for short. Currently there are two versions on Github, with possibly more coming. There is also an active channel of developers and users on Slack which can be found under or @ #A-Frame, #networked-aframe.

So here is the story of why there are two NAFs.

Here are the two repositories:

The Original:

The Forked Mozilla:

However, for this article I will be using the original. The Mozilla’s fork is used with Hubs with a specific adapter (not easyrtc). The documentation and easyrtc adapter are not maintained in the Mozilla’s fork. Mozilla’s fork may contains other fixes that can be good for users outside of Hubs but it may also contain changes that work only with Hubs in the future.

Currently the original repository contains all the fixes of the Mozilla’s fork and the repository maintainer says “if in the future there is an interesting fix in the Mozilla’s fork, it will be ported to the original repository”.

My Windows VPS Setup

So here is where it gets technical. My background is in Windows servers, not Linux. So, although I’d probably recommend you setting up your server with a Unix based OS, mine is going to be setup with a Standard Windows Server 2019 VPS, Ok?

You could go cloud as well, as long as you have a dedicated IP address and you’ll need an SSL certificate for the VR to work. We’ll get that for free in a bit.

The are a number of different providers of VPS (Virtual Private Server) on the internet. Shop around (mama told me), and choose one that matches your monthly budget. Mine is a 2 CPU core slice Windows Server 2019 (AMD64) at https://www.interserver.net/ .

Now, domain name. I had an old domain name already, funbit64.com which I was going to use for another project, but instead decided to repurpose as my VR sandbox server domain name. If you purchase one from your host provider, you should be good. If not get an admin to help you to redirect your domain name DNS to the (usually 2) Namesevers of your selected provider.

Now you have your VPS and domain name. Setup you windows server, being sure to harden it securely with at the least all the necessary windows security updates (very important).

Did I say this was going to be easy? No. Your attention to detail is critical in all the following steps.

Setup and configure Remote Desktop to access your new Windows Server VPS. If it’s not enabled already, enable remote desktop on the server. Then set it up on your client machine.

The next part involves setting up the iisnode configuration on the server. Now there are ways to do a reverse proxy for this. Don’t! Why? Because your going to need sockets setup properly and the iisnode approach will do that for you.

Follow this video patiently, which was very helpful in my setup. He is setting the server up for a different Node.js project, but over 90% of the configuration is the same for us. Don’t worry that he is setting up a server 2012 configuration, it works for 2019 as well. I did not have to add an A record, mine was already setup.

STOP about 12:33 minutes into the video, he begins cloning his project into his DATA directory. This is where we will diverge. Instead we will clone our networked-aframe project into a different NAF directory.

Setting up a Windows Server 2019 for IISnode and WebRTC
NAF directory created under C:\Users\Administrator\NAF

Open a COMMAND prompt window, your path should start at “C:\users\Administrator” and

git clone https://github.com/networked-aframe/networked-aframe.git
rename networked-aframe NAF
cd NAF
rename examples public_html
dir
cd server

Using your favorite file editor on the server, paste, save and replace this file named “easyrtc-server.js” to the /server directory overwriting the existing file.

//
// Example modification to read SSL certificate file and start https:
//
// see: https://stackoverflow.com/questions/42753566/nodejs-load-pfx-certificate-from-file
//
//const https = require('https');
//const fs = require('fs');
//const options = {
// pfx: fs.readFileSync('test/fixtures/test_cert.pfx'),
// passphrase: 'sample'
//};
//https.createServer(options, (req, res) => {
// res.writeHead(200);
// res.end('hello world\n');
//}).listen(8000);
// Load required modules
const https = require("https"); // https server core module
const path = require("path");
const express = require("express"); // web framework external module
const socketIo = require("socket.io"); // web socket external module
const easyrtc = require("open-easyrtc"); // EasyRTC external module
const fs = require('fs'); // file system module
const options = {
pfx: fs.readFileSync('C:/ProgramData/certify/assets/yourdomainname.com/YourCertificateFilename.pfx'), //lets get our SSL certificate (must change when it expires)
//passphrase: 'testifitworks'
};
// Set process name
process.title = "networked-aframe-server";
// Get port or default to 3025
const port = process.env.PORT || 3025;
// Setup and configure Express http server.
const app = express();
app.use(express.static(path.resolve(__dirname, "..", "public_html")));
// Serve the example and build the bundle in development.
if (process.env.NODE_ENV === "development") {
const webpackMiddleware = require("webpack-dev-middleware");
const webpack = require("webpack");
const config = require("../webpack.config");
app.use(
webpackMiddleware(webpack(config), {
publicPath: "/dist/"
})
);
}
// Start Express http server
const webServer = https.createServer(options,app); // we are now using a SSL certificate (see top comments above)
// Start Socket.io so it attaches itself to Express server
const socketServer = socketIo.listen(webServer, {"log level": 1});
const myIceServers = [
{"urls":"stun:stun1.l.google.com:19302"},
{"urls":"stun:stun2.l.google.com:19302"},
// {
// "urls":"turn:[ADDRESS]:[PORT]",
// "username":"[USERNAME]",
// "credential":"[CREDENTIAL]"
// },
// {
// "urls":"turn:[ADDRESS]:[PORT][?transport=tcp]",
// "username":"[USERNAME]",
// "credential":"[CREDENTIAL]"
// }
];
easyrtc.setOption("appIceServers", myIceServers);
easyrtc.setOption("logLevel", "debug");
easyrtc.setOption("demosEnable", false);
// Overriding the default easyrtcAuth listener, only so we can directly access its callback
easyrtc.events.on("easyrtcAuth", (socket, easyrtcid, msg, socketCallback, callback) => {
easyrtc.events.defaultListeners.easyrtcAuth(socket, easyrtcid, msg, socketCallback, (err, connectionObj) => {
if (err || !msg.msgData || !msg.msgData.credential || !connectionObj) {
callback(err, connectionObj);
return;
}
connectionObj.setField("credential", msg.msgData.credential, {"isShared":false});console.log("["+easyrtcid+"] Credential saved!", connectionObj.getFieldValueSync("credential"));callback(err, connectionObj);
});
});
// To test, lets print the credential to the console for every room join!
easyrtc.events.on("roomJoin", (connectionObj, roomName, roomParameter, callback) => {
console.log("["+connectionObj.getEasyrtcid()+"] Credential retrieved!", connectionObj.getFieldValueSync("credential"));
easyrtc.events.defaultListeners.roomJoin(connectionObj, roomName, roomParameter, callback);
});
// Start EasyRTC server
const rtc = easyrtc.listen(app, socketServer, null, (err, rtcRef) => {
console.log("Initiated");
rtcRef.events.on("roomCreate", (appObj, creatorConnectionObj, roomName, roomOptions, callback) => {
console.log("roomCreate fired! Trying to create: " + roomName);
appObj.events.defaultListeners.roomCreate(appObj, creatorConnectionObj, roomName, roomOptions, callback);
});
});
// Listen on port
webServer.listen(port, () => {
console.log("listening on http://localhost:" + port);
});
cd ..

Add this file for the server configuration and save it in the root of the NAF directory C:\users\Adminitrator\NAF\ you renamed as filename “web.config”.

<configuration>
<system.webServer>
<!-- indicates that the index.js file is a node.js application
to be handled by the iisnode module -->
<handlers>
<add name="iisnode" path="/server/easyrtc-server.js" verb="*" modules="iisnode" />
<!-- <add name="iisnode-socketio" path="server-socketio.js" verb="*" modules="iisnode" /> -->
</handlers>

<!-- indicate that all static the URL paths begining with 'socket.io' should be redirected to the index.js node.js applicationn to avoid IIS attempting to serve that content using other handlers (e.g static file handlers) -->
<rewrite>
<rules>
<!-- <rule name="LogFile" patternSyntax="ECMAScript">
<match url="socket.io"/>
<action type="Rewrite" url="server-socketio.js"/>
</rule> -->
<rule name="StaticContent" patternSyntax="Wildcard">
<action type="Rewrite" url="public{REQUEST_URI}" logRewrittenUrl="true" />
<conditions>
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
</conditions>
<match url="*.*" />
</rule>
<rule name="DynamicContent">
<conditions>
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
</conditions>
<action type="Rewrite" url="/server/easyrtc-server.js" logRewrittenUrl="true" />
<match url="/*" />
</rule>
</rules>
</rewrite>
<!--iisnode loggingEnabled="true" /-->
<!-- disable the IIS websocket module to allow node.js to provide its own WeSocket implementatiion -->
<webSocket enabled="false" />
</system.webServer>
<appSettings>
<add key="NODE_ENV" value="production" />
</appSettings>
</configuration>

From the command prompt

npm install

NOW A FEW THINGS BEFORE WE CONTINUE

So up to this point we have created a NAF directory and modified a few files.

The /server/easyrtc-server.js file is the one we will run in node to act as our secondary webserver handling all the NAF requests on port 3025. This is configured in the file. Also in the file is the SSL certificate you will need to change to your certificate.

Make note of the filename and path stored to your certificate generated in “Certify the Web” application. Replace “YourCertificateFilename.pfx” on aproximately line 31 of the easyrtc-server.js file. This makes sure that the certificate you are using in IIS matches the certificate used in the Node webserver; or you will get an error.

Now continue the video above at time index 15:09 to 17:23. This will basically explain the web.config file you created in the root of the NAF directory.

node configuration in IIS (Basic Settings)

Now find your iisnode directory here C:\Program Files\iisnode\

Go down into www

Create an empty NAF directory

cd \
cd "Program Files\iisnode"
cd www
md NAF

This is kind of a dummy directory with nothing in it. We will be running the Node.js server as a separate process. But iisnode should pass through on port 3025 and SSL will be returned to the browser on the client side.

Open Internet Information Server (IIS) manager console and select node under the default website. See screenshot above. Choose “Basic Settings” on the right control panel.

Set the Physical Path to:

C:\Program Files\iisnode\www\NAF

The Application Pool should be “Node Web Site”, press OK.

Now finally, let’s launch the node server; open a command prompt:

cd NAF
npm run dev

Cntl-c and y on the keyboard will exit the process, but don’t do that yet. You now have a server listening on port 3025. Go to a client browser, off of your server, and type your domain name into the browser URL address using https:// followed by your domain name, followed by a :3025.

Mine is: https://funbit64.com:3025/

You should get something like this . . .

This means your NAF setup is working

That should do it for server configuration. However now you need the A-frame repositories copied into the public_html directory of your node server.

Copy the following repositories into your public_html directory (if you want to host them yourself, I do).

I encountered some problems with the latest a-frame master and NAF. So I use the A-Frame V1.1.0 (you may not have to):

You’ll also need:

and the following you should put in the js directory, which you will need to create . . .

https://funbit64.com:3025/js/spawn-in-circle.component.js

Once you have all of the above A-Frame components installed on your server you can use this download zip file of a few more assets (use the source code in this article). Then modify or use the code below to test out an environment from which to build further. You should name it NAF-audio.html and put the file in the public_html folder to server it from a client browser outside the server.

<!DOCTYPE html>
<html>
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<title>Multi-user VR Office (loading assets . . . please wait)</title>
<meta name="description" content="Virtual Reality Office Space">
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0, shrink-to-fit=no">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="gray-translucent" />
<script src="https://rocketvirtual.com/aframePACKAGE/aframe-master/dist/aframe-v1.1.0.min.js"></script>
<script src="https://rocketvirtual.com/aframePACKAGE/aframe-environment-component-master/dist/aframe-environment-component.min.js"></script>
<script src="https://rocketvirtual.com/aframePACKAGE/aframe-extras-master/dist/aframe-extras.min.js"></script>
<script src="https://rocketvirtual.com/aframePACKAGE/aframe-teleport-controls-master/dist/aframe-teleport-controls.js"></script>
<script src="https://rocketvirtual.com/aframePACKAGE/superframe-master/components/thumb-controls/dist/aframe-thumb-controls-component.min.js"></script>
<script src="https://rocketvirtual.com/aframePACKAGE/aframe-lensflare-component-master/dist/aframe-lensflare-component.min.js"></script>
<script src="https://rocketvirtual.com/aframePACKAGE/superframe-master/components/text-geometry/dist/aframe-text-geometry-component.min.js"></script>
<script src="https://rocketvirtual.com/aframePACKAGE/aframe-alongpath-component-master/dist/aframe-alongpath-component.min.js" ></script>
<script src="https://rocketvirtual.com/aframePACKAGE/aframe-curve-component-master/dist/aframe-curve-component.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.3.0/socket.io.slim.js"></script>
<script src="/easyrtc/easyrtc.js"></script>
<script src="/dist/networked-aframe.js"></script>
<script src="https://unpkg.com/aframe-randomizer-components@^3.0.1/dist/aframe-randomizer-components.min.js"></script>
<script src="/js/spawn-in-circle.component.js"></script>
<!-- The JavaScript below provides for selection and action on objects, environment and audio -->
<script type="text/javascript">
// Audio squawk for the duck
var squawk = new Audio('https://rocketvirtual.com/aframePACKAGE/assets/mp3/duck.mp3');
// Function to execute onclick
function fire_laser() {
// Make Duck Quack sound
squawk.play();
//Disappear Duck
document.getElementById('movingDuck').setAttribute('visible', 'false');
}
function doCylinder() {

// Bring back visibility of Duck and Cube, turn Sphere red again
document.getElementById('movingDuck').setAttribute('visible', true);
document.getElementById('cube').setAttribute('visible', true);
document.getElementById('sphere').setAttribute('color', 'red');
}
function doCube() {

// Make Sphere green, disappear Cube for now
document.getElementById('sphere').setAttribute('color', 'green');
document.getElementById('cube').setAttribute('visible', false);
}
// Component to do on click.
AFRAME.registerComponent('click-listener', {
init: function () {
this.el.addEventListener('click', function (evt) {// remove clicked object from view
//this.setAttribute('visible', false);

});
}
});
// Solves Google Chrome mute of audio https://stackoverflow.com/questions/47921013/play-sound-on-click-in-a-frame?answertab=active#tab-top
AFRAME.registerComponent('audiohandler', {
init:function() {
let playing = false;
let audio = document.querySelector("#playAudio");
this.el.addEventListener('click', () => {
if(!playing) {
audio.play();
} else {
audio.pause();
audio.currentTime = 0;
}
playing = !playing;
});
}
})
</script></head>
<body>
<a-scene networked-scene="
room: basic;
debug: true;
adapter: easyrtc;
audio: true;
">
<a-assets>
<!-- Our font -->
<a-asset-item id="optimerBoldFont" src="assets/fonts/optimer_bold.typeface.json"></a-asset-item>
<img crossorigin="anonymous" id="flare-asset" src="assets/img/adjustflare.jpg"><a-asset-item id="Building" src="assets/gltf/New_Building_final1.glb" nav-agent="speed: 1.5; active: true"></a-asset-item><a-asset-item id="Robot1" src="assets/gltf/robot_face_orange.glb"></a-asset-item>
<a-asset-item id="Robot2" src="assets/gltf/robot_green.glb"></a-asset-item>
<a-asset-item id="Robot3" src="assets/gltf/robot_orange.glb"></a-asset-item>
<a-asset-item id="Robot4" src="assets/gltf/robot_purple.glb"></a-asset-item>
<a-asset-item id="Robot5" src="assets/gltf/robot_red.glb"></a-asset-item>
<a-asset-item id="RobotHead" src="assets/gltf/robot_face.glb"></a-asset-item>
<a-asset-item id="Robot6" src="assets/gltf/robot_white.glb"></a-asset-item>
<a-asset-item id="SwivelChair" src="assets/gltf/chair4.glb"></a-asset-item>
<a-asset-item id="Table1" src="assets/gltf/Table_curved_legs.glb"></a-asset-item>
<a-asset-item id="Computer_Desk1" src="assets/gltf/computer_Desk.glb"></a-asset-item>
<a-asset-item id="Shelf3" src="assets/gltf/shelf4.glb"></a-asset-item>
<a-asset-item id="LHand" src="assets/gltf/leftHandLow.glb"></a-asset-item>
<a-asset-item id="RHand" src="assets/gltf/rightHandLow.glb"></a-asset-item>
<!-- Duck 3D GltF model -->
<a-asset-item id="duck" src="assets/gltf/Duck.glb"></a-asset-item>

<template id="avatar-template">
<a-entity networked-audio-source></a-entity>
</template>

<template id="head-template">
<a-entity class="cam" gltf-model="#RobotHead" scale="0.008 0.008 0.008"></a-entity>
</template>

<template id="hand-left">
<a-entity class="leftController">
<!-- <a-box class="box" scale="0.1 0.1 0.1"></a-box> -->
<a-entity gltf-model="#LHand" ></a-entity>
</a-entity>
</template>
<template id="hand-right">
<a-entity class="rightController">
<!-- <a-box class="box" scale="0.1 0.1 0.1"></a-box> -->
<a-entity gltf-model="#RHand" ></a-entity>
</a-entity>
</template>
</a-assets><a-entity id="mouseCursor" cursor="rayOrigin: mouse"></a-entity><!-- nav-mesh: protecting us from running thru walls -->
<a-entity id="navmesh-walls" gltf-model="assets/gltf/navmeshOffice.gltf" visible="false" nav-mesh=""></a-entity>
<!-- Basic movement, selection and teleport -->
<a-entity id="avatar" networked="template:#avatar-template;attachTemplateToLocal:false;"
movement-controls="constrainToNavMesh: true;" spawn-in-circle="radius:3">
<a-entity class="cam" networked="template:#head-template;attachTemplateToLocal:false;" camera="active: true"
position="0 1.6 0" look-controls></a-entity>
<a-entity class="leftController" networked="template:#hand-left;attachTemplateToLocal:false;"
hand-controls="hand: left; handModelStyle: lowPoly; color: #15ACCF" teleport-controls="cameraRig: #avatar; button: trigger; curveShootingSpeed: 11; collisionEntities: #navmesh-walls;" visible="true" ></a-entity>
<a-entity class="rightController" networked="template:#hand-right;attachTemplateToLocal:false;"
hand-controls="hand: right; handModelStyle: lowPoly; color: #15ACCF" laser-controls raycaster="showLine: true; far: 10; interval: 0; objects: .clickable, a-link;" line="color: #7cfc00; opacity: 0.5" visible="true"></a-entity>
</a-entity>
<a-entity id="Meeting_Structure" gltf-model="#Building" position="-10 0.01 12" rotation="0 0 0" scale="0.019 0.019 0.019" shadow=""></a-entity>
<a-box id="ramp" position="0.98334 2.29677 17.7" rotation="-67.55516179269485 0.3609634109324186 -0.21314029978866625" scale="1 3.75116 0.11246" width="4" height="4" color="#FFFFFF" shadow="receive: true"></a-box>
<!-- Some 3D Text -->
<a-entity position="-3.234 3.839 -3.628" text-geometry="value: Virtual Office Space; font: #optimerBoldFont" material="color: #98BE9C"></a-entity>
<!-- Place the Sun -->
<a-sphere id="flare" radius="0.05" color="yellow" lensflare="createLight:false; relative: true; src: #flare-asset; position:-109.093 10.428 -46.857; lightColor:yellow; intensity: 5; lightDecay: 500" position="-109.093 26.20844 -14.03708"></a-sphere>
<!-- Our Environment -->
<a-entity environment="preset: forest; playArea: 1.2; dressingAmount: 250; dressingUniformScale: false; dressingColor: #248728; ground: hills; groundTexture: squares; groundColor: #34711C; groundColor2: #34711C; grid: 1x1; gridColor: #81F995; skyType: gradient; skyColor: #A4CFFA; horizonColor: #95C8FA; fog: .75" shadow></a-entity>
<a-entity light="type: ambient; intensity: 0.45"></a-entity><!-- A light in the scene casting shadows -->
<a-entity light="intensity: 0.5; castShadow: true; shadowCameraLeft: -50; shadowCameraBottom: -50; shadowCameraRight: 50; shadowCameraTop: 50; shadowCameraVisible: false" position="-85.28485 65.36529 -12.29375"></a-entity>
<!-- Normal Hello World objects modified to respond to onclick and audio -->
<a-box id="cube" class="clickable" position="-1 5.79285 -3" rotation="0 45 0" color="#4CC3D9" visible="true" shadow onclick="doCube();" click-listener></a-box>
<a-sphere id="sphere" class="clickable" position="0 6.51879 -5" radius="1.25" color="#EF2D5E" shadow audiohandler></a-sphere>
<a-cylinder id="cylinder" class="clickable" position="1 6.01832 -3" radius="0.5" height="1.5" color="#FFC65D" shadow onclick="doCylinder();" click-listener></a-cylinder>
<!-- Moving Duck -->
<a-entity id="movingDuck" class="clickable" gltf-model="#duck" alongpath="curve:#track;loop:true;dur:14000;rotate:true" position="0 1.6 -5" shadow="receive:false" scale="1 1 1" animation__rotate="property: rotation; dur: 2000; easing: linear; loop: true; to: 0 360 0" shadow onclick="fire_laser();" cursor-listener></a-entity>
<!-- A track for our Duck to follow -->
<a-curve id="track" >
<a-curve-point position="0 6 8" geometry="height:0.1;width:0.1;depth:0.1" material="color:#ff0000" curve-point="" visible="false"></a-curve-point>
<a-curve-point position="5 6 6" geometry="height:0.1;width:0.1;depth:0.1" material="color:#ff0000" curve-point="" visible="false"></a-curve-point>
<a-curve-point position="7 6 0" geometry="height:0.1;width:0.1;depth:0.1" material="color:#ff0000" curve-point="" visible="false"></a-curve-point>
<a-curve-point position="5 6 -5" geometry="height:0.1;width:0.1;depth:0.1" material="color:#ff0000" curve-point="" visible="false"></a-curve-point>
<a-curve-point position="0 6 -7" geometry="height:0.1;width:0.1;depth:0.1" material="color:#ff0000" curve-point="" visible="false"></a-curve-point>
<a-curve-point position="-6 6 -5" geometry="height:0.1;width:0.1;depth:0.1" material="color:#ff0000" curve-point="" visible="false"></a-curve-point>
<a-curve-point position="-8 6 0" geometry="height:0.1;width:0.1;depth:0.1" material="color:#ff0000" curve-point="" visible="false"></a-curve-point>
<a-curve-point position="-6 6 6" geometry="height:0.1;width:0.1;depth:0.1" material="color:#ff0000" curve-point="" visible="false"></a-curve-point>
<a-curve-point position="0 6 8" geometry="height:0.1;width:0.1;depth:0.1" material="color:#ff0000" curve-point="" visible="false"></a-curve-point>
</a-curve>
<!-- The track line in red -->
<a-draw-curve curveref="#track" material="shader: line; color: red;" visible="false"></a-draw-curve>
<!-- Office Furniture -->
<a-entity id="Office_Swivel_Chair1" gltf-model="#SwivelChair" position="-4.50984 1.4088 -7.49469" rotation="0 51.53067817847597 0" scale="0.014 0.014 0.014" shadow visible=""></a-entity>
<a-entity id="Office_Swivel_Chair2" gltf-model="#SwivelChair" position="-1.08802 1.4088 -7.49469" rotation="0 -77.14704824097996 0" scale="0.014 0.014 0.014" shadow="" visible=""></a-entity>
<!-- <a-entity id="Table_curved_legs" gltf-model="#Table1" position="26.448 2.443 -24.856" rotation="0 -5.872 0" scale="0.0012 .0012 .0012" shadow visible=""></a-entity> -->
<a-entity id="Computer_Desk" gltf-model="#Computer_Desk1" position="-3.53831 0.00717 -7.49088" rotation="0 -89.90223467618235 0" scale="0.013 0.013 0.013" shadow visible=""></a-entity>
<a-entity id="Shelf3" gltf-model="#Shelf3" position="-1.38896 -0.07179 -4.27878" rotation="0 -180 0" scale="0.003 0.003 0.003" shadow visible=""></a-entity>

<!-- Robots Sample Only (video face plate)
<a-entity id="Robot One" gltf-model="#Robot1" position="-2.02327 1.75422 -4.2609" rotation="0 0 0" scale="0.008 0.008 0.008" shadow visible=""></a-entity> -->
<a-entity id="Robot Green" gltf-model="#Robot2" position="-2.6114 1.18304 -7.26037" rotation="0 118.3885503344966 0" scale="0.003 0.003 0.003" shadow visible=""></a-entity>
<a-entity id="Robot Orange" gltf-model="#Robot3" position="-1.45573 1.47969 -4.34298" rotation="0 -177.42784041816202 0" scale="0.003 0.003 0.003" shadow visible=""></a-entity>
<a-entity id="Robot Purple" gltf-model="#Robot4" position="-2.6114 1.18304 -6.8313" rotation="0 89.96239524467109 0" scale="0.003 0.003 0.003" shadow visible=""></a-entity>
<a-entity id="Robot Red" gltf-model="#Robot5" position="-2.6114 1.18304 -7.68767" rotation="0 56.14012364030345 0" scale=".003 .003 .003" shadow visible=""></a-entity>
<a-entity id="Robot Large" gltf-model="#Robot6" position="-2.6114 1.291 -8.647" rotation="0 -26.999 0" scale=".005 .005 .005" shadow visible=""></a-entity>

</a-scene>
<script>
/**
* NAF Setup
*/
NAF.schemas.add({
template: '#avatar-template',
components: [
'position',
'rotation',
]
});
NAF.schemas.add({
template: '#head-template',
components: [
'position',
'rotation',
]
});
NAF.schemas.add({
template: '#hand-left',
components: [
'position',
'rotation',
]
});
NAF.schemas.add({
template: '#hand-right',
components: [
'position',
'rotation',
]
});
function onConnect() {
console.log("connected to a room!");
}
</script>
</body>
</html>

Now go into a browser (not on your server) and enter this URL to test:

https://yourdomain.com:3025/NAF-audio.html

To start your server process after rebooting the machine. Open a command window:

cd NAF
npm start

Just so you know, A-Frame and NAF, even WebXR, and the browser updates are all changing and evolving fairly regularly. Just recently video capability was added. So with that said, I can’t guarantee everything in this post will stay accurate and up to date over an extended period of time. You should always default to the current documentation on all the repositories mentioned here to get the latest corrections for this article.

Enjoy your multi-user VR office space! Clap if you liked my article, and visit my VR blog for recent posts.

Founder, VR coder, Sci-Fi Blogger with ideas for human technological-evolution.