diff --git a/.htmlhintrc b/.htmlhintrc
new file mode 100644
index 0000000000000000000000000000000000000000..86dbac70f2f2827ba3bb98eae879dc285d00def6
--- /dev/null
+++ b/.htmlhintrc
@@ -0,0 +1,25 @@
+{
+  "alt-require": false,
+  "attr-lowercase": true,
+  "attr-no-duplication": true,
+  "attr-unsafe-chars": true,
+  "attr-value-double-quotes": true,
+  "attr-value-not-empty": false,
+  "doctype-first": true,
+  "doctype-html5": true,
+  "head-script-disabled": false,
+  "href-abs-or-rel": false,
+  "id-class-ad-disabled": false,
+  "id-class-value": "dash",
+  "id-unique": true,
+  "inline-script-disabled": false,
+  "inline-style-disabled": true,
+  "space-tab-mixed-disabled": "space2",
+  "spec-char-escape": true,
+  "src-not-empty": true,
+  "style-disabled": false,
+  "tag-pair": true,
+  "tag-self-close": false,
+  "tagname-lowercase": true,
+  "title-require": true
+}
diff --git a/.travis.yml b/.travis.yml
index 73a40d0761a7b442cd2435b7ac37a59f5c13a875..890fcfd3ac3ca6f4d4ac9507586110c817dc3edf 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -5,4 +5,6 @@ before_install:
   - curl -o- -L https://yarnpkg.com/install.sh | bash -s -- --version 1.5.1
   - export PATH="$HOME/.yarn/bin:$PATH"
 install: yarn 
-script: yarn lint
+script:
+- yarn lint
+- ./scripts/check-yarn-lock.sh
diff --git a/package.json b/package.json
index 1b0e87daa003040077505b1403c365ecc97c5c36..d14d1c7a5166f8cf321335363872fa2da4806c6b 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,9 @@
     "start": "cross-env NODE_ENV=development webpack-dev-server",
     "build": "rimraf ./public && cross-env NODE_ENV=production webpack --mode=production",
     "prettier": "prettier --write '*.js' 'src/**/*.js'",
-    "lint": "eslint '*.js' 'src/**/*.js'"
+    "lint:js": "eslint '*.js' 'scripts/**/*.js' 'src/**/*.js'",
+    "lint:html": "node ./scripts/lint-html.js 'src/**/*.html'",
+    "lint": "yarn run lint:js && yarn run lint:html"
   },
   "dependencies": {
     "@fortawesome/fontawesome": "^1.1.5",
@@ -22,7 +24,7 @@
     "aframe-extras": "^4.0.0",
     "aframe-input-mapping-component": "https://github.com/johnshaughnessy/aframe-input-mapping-component#feature/map-to-array",
     "aframe-physics-extras": "https://github.com/infinitelee/aframe-physics-extras#fix/physics-collider-crash",
-    "aframe-physics-system": "https://github.com/donmccurdy/aframe-physics-system",
+    "aframe-physics-system": "https://github.com/infinitelee/aframe-physics-system#feature/shape-component",
     "aframe-rounded": "^1.0.3",
     "aframe-slice9-component": "^1.0.0",
     "aframe-teleport-controls": "^0.3.1",
@@ -33,10 +35,13 @@
     "jsonschema": "^1.2.2",
     "minijanus": "^0.5.0",
     "mobile-detect": "^1.4.1",
+    "moment": "^2.22.0",
+    "moment-timezone": "^0.5.14",
     "moving-average": "^1.0.0",
     "naf-janus-adapter": "https://github.com/mozilla/naf-janus-adapter#feature/disconnect",
     "networked-aframe": "https://github.com/mozillareality/networked-aframe#mr-social-client/master",
     "nipplejs": "^0.6.7",
+    "phoenix": "^1.3.0",
     "query-string": "^5.0.1",
     "raven-js": "^3.20.1",
     "react": "^16.1.1",
@@ -67,12 +72,14 @@
     "file-loader": "^1.1.10",
     "html-loader": "^0.5.5",
     "html-webpack-plugin": "^3.1.0",
+    "htmlhint": "^0.9.13",
     "lodash": "^4.17.5",
     "node-sass": "^4.7.2",
     "prettier": "^1.7.0",
     "rimraf": "^2.6.2",
     "sass-loader": "^6.0.7",
     "selfsigned": "^1.10.2",
+    "shelljs": "^0.8.1",
     "style-loader": "^0.20.2",
     "webpack": "^4.0.1",
     "webpack-cli": "^2.0.9",
diff --git a/scripts/check-yarn-lock.sh b/scripts/check-yarn-lock.sh
new file mode 100755
index 0000000000000000000000000000000000000000..e27b56ab47bc9746933266a02f0c30b7a7b9c6e1
--- /dev/null
+++ b/scripts/check-yarn-lock.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+
+if [ `git diff yarn.lock | wc -l` -ne 0 ]; then
+  echo ""
+  tput setaf 1
+  echo "!! UNCOMMITED YARN.LOCK CHANGES !!"
+  tput sgr0
+  echo ""
+  exit 1
+fi
diff --git a/scripts/default.env b/scripts/default.env
index 0fd18aa5be8791b61c69d2587c4611ef7f1f6b70..a1186bbf1f20d84c6883a36bedea1e6ac88e4e7c 100644
--- a/scripts/default.env
+++ b/scripts/default.env
@@ -1,5 +1,5 @@
 # This origin trial token is used to enable WebVR and Gamepad Extensions on Chrome 62+
 # You can find more information about getting your own origin trial token here: https://github.com/GoogleChrome/OriginTrials/blob/gh-pages/developer-guide.md
-ORIGIN_TRIAL_TOKEN="AvIMoF4hyRZQVfSfksoqP+7qzwa4FSBzHRHvUyzC8rMATJVRbcOiLewBxbXtJVyV3N62gsZv7PoSNtDqqtjzYAcAAABkeyJvcmlnaW4iOiJodHRwczovL3JldGljdWx1bS5pbzo0NDMiLCJmZWF0dXJlIjoiV2ViVlIxLjFNNjIiLCJleHBpcnkiOjE1MTYxNDYyMDQsImlzU3ViZG9tYWluIjp0cnVlfQ==",
-ORIGIN_TRIAL_EXPIRES="2018-05-15",
+ORIGIN_TRIAL_TOKEN="ArEZ0vY0uMo3pj+oY8Up4u4Hy8QolJwKxG4/2WRhSPnTZRrviiGhzP6/y72nBdsIhdEyoundxqg//KLbs2vGnQoAAABkeyJvcmlnaW4iOiJodHRwczovL3JldGljdWx1bS5pbzo0NDMiLCJmZWF0dXJlIjoiV2ViVlIxLjFNNjIiLCJleHBpcnkiOjE1MjYzNDg2MjEsImlzU3ViZG9tYWluIjp0cnVlfQ=="
+ORIGIN_TRIAL_EXPIRES="2018-05-15"
 JANUS_SERVER="wss://prod-janus.reticulum.io"
diff --git a/scripts/lint-html.js b/scripts/lint-html.js
new file mode 100644
index 0000000000000000000000000000000000000000..d8891e836d3e9ed9cf0a8b7dea0aee107188a6fe
--- /dev/null
+++ b/scripts/lint-html.js
@@ -0,0 +1,36 @@
+#!/usr/bin/env node
+
+const { promisify } = require("util");
+const fs = require("fs");
+const mkdtemp = promisify(fs.mkdtemp);
+const path = require("path");
+const os = require("os");
+const shell = require("shelljs");
+
+(async function() {
+  function lintFile(tempDir, arg, file) {
+    const out = path.join(tempDir, file);
+    shell.mkdir("-p", path.dirname(out));
+    shell.sed(/<%.+%>/, "", file).to(out);
+    const result = shell.exec(`node_modules/.bin/htmlhint ${arg} --config=.htmlhintrc ${out}`);
+    return result.code;
+  }
+
+  let result = 0;
+  if (process.argv.length > 2) {
+    const tempDir = await mkdtemp(path.join(os.tmpdir(), "lint-html-"));
+    let files;
+    let arg = "";
+    if (process.argv.length === 4) {
+      arg = process.argv[2];
+      files = process.argv[3];
+    } else {
+      files = process.argv[2];
+    }
+    const results = shell.ls(files).map(lintFile.bind(null, tempDir, arg));
+    result = results.reduce((a, r) => a + r, 0);
+    shell.rm("-r", tempDir);
+  }
+
+  shell.exit(result);
+})();
diff --git a/src/assets/images/dropdown_arrow.png b/src/assets/images/dropdown_arrow.png
new file mode 100755
index 0000000000000000000000000000000000000000..caa42c1ffed82796540acdc192201cf20e822e0b
Binary files /dev/null and b/src/assets/images/dropdown_arrow.png differ
diff --git a/src/assets/images/dropdown_arrow@2x.png b/src/assets/images/dropdown_arrow@2x.png
new file mode 100755
index 0000000000000000000000000000000000000000..d4e74eb212652021837a17d860578c6f7114dcd5
Binary files /dev/null and b/src/assets/images/dropdown_arrow@2x.png differ
diff --git a/src/assets/images/level_background.png b/src/assets/images/level_background.png
new file mode 100755
index 0000000000000000000000000000000000000000..9d53b3c6dc75552b225d5717fa6fb8cd883b05cb
Binary files /dev/null and b/src/assets/images/level_background.png differ
diff --git a/src/assets/images/level_background@2x.png b/src/assets/images/level_background@2x.png
new file mode 100755
index 0000000000000000000000000000000000000000..4a9f08acc76396f4133673faed5b4ae38ca6cc87
Binary files /dev/null and b/src/assets/images/level_background@2x.png differ
diff --git a/src/assets/images/level_fill.png b/src/assets/images/level_fill.png
old mode 100644
new mode 100755
index 99f77b5655e6a50e0444364a3c2cfb4882b3b2d9..49a4f8a75064870db870bf994e8b25671205bfb0
Binary files a/src/assets/images/level_fill.png and b/src/assets/images/level_fill.png differ
diff --git a/src/assets/images/level_fill@2x.png b/src/assets/images/level_fill@2x.png
old mode 100644
new mode 100755
index 477d9801bb6d33737b571fce454ff265ff79e77c..28f313bc9d541fc92fd65c03945b35c1affdf9cb
Binary files a/src/assets/images/level_fill@2x.png and b/src/assets/images/level_fill@2x.png differ
diff --git a/src/assets/images/mic_level.png b/src/assets/images/mic_level.png
old mode 100644
new mode 100755
index 5be15458d9ed41c46f861d8dd8435a11e452f80c..e4c1367ddf78efd48173a3d0a64c4c48c953a871
Binary files a/src/assets/images/mic_level.png and b/src/assets/images/mic_level.png differ
diff --git a/src/assets/images/mic_level@2x.png b/src/assets/images/mic_level@2x.png
old mode 100644
new mode 100755
index 94739aa1977cc5d5317eeb770905ed212ff248b4..621f944ed0b07b1a625a2627f5646406fcefbd98
Binary files a/src/assets/images/mic_level@2x.png and b/src/assets/images/mic_level@2x.png differ
diff --git a/src/assets/images/speaker_level.png b/src/assets/images/speaker_level.png
old mode 100644
new mode 100755
index 9ccedcc0350f90c95744d928128594829b5f5b90..f0557615258997bb7c54e7a6028e052c9c8a33f4
Binary files a/src/assets/images/speaker_level.png and b/src/assets/images/speaker_level.png differ
diff --git a/src/assets/images/speaker_level@2x.png b/src/assets/images/speaker_level@2x.png
old mode 100644
new mode 100755
index a807745cbcaaf823e6e8e99deda15459d1ed1d9a..3d60f4b8d287742ad3076ae7e63f988cca029f89
Binary files a/src/assets/images/speaker_level@2x.png and b/src/assets/images/speaker_level@2x.png differ
diff --git a/src/assets/interactables/duck/DuckyMesh.glb b/src/assets/interactables/duck/DuckyMesh.glb
index 5bb10e0cd79c623f72f87d438f6eddbe1d0dc250..f50dc37942d519c36e16a5e41bb8a44a18c4c7f1 100644
Binary files a/src/assets/interactables/duck/DuckyMesh.glb and b/src/assets/interactables/duck/DuckyMesh.glb differ
diff --git a/src/assets/interactables/duck/gltf/DuckyMesh.fbm/Ducky.jpg b/src/assets/interactables/duck/gltf/DuckyMesh.fbm/Ducky.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..6c27845f40d1e4b91ddb5534c413f408580ed51e
Binary files /dev/null and b/src/assets/interactables/duck/gltf/DuckyMesh.fbm/Ducky.jpg differ
diff --git a/src/assets/interactables/duck/gltf/DuckyMesh.gltf b/src/assets/interactables/duck/gltf/DuckyMesh.gltf
new file mode 100644
index 0000000000000000000000000000000000000000..83cc03646480c7cec3849fda0a8fd3580cec3abc
--- /dev/null
+++ b/src/assets/interactables/duck/gltf/DuckyMesh.gltf
@@ -0,0 +1,248 @@
+{
+  "asset": {
+    "generator": "FBX2glTF",
+    "version": "2.0"
+  },
+  "scene": 0,
+  "buffers": [
+    {
+      "byteLength": 18600,
+      "uri": "buffer.bin"
+    }
+  ],
+  "bufferViews": [
+    {
+      "buffer": 0,
+      "byteLength": 3336,
+      "byteOffset": 0,
+      "target": 34963
+    },
+    {
+      "buffer": 0,
+      "byteLength": 5724,
+      "byteOffset": 3336,
+      "target": 34962
+    },
+    {
+      "buffer": 0,
+      "byteLength": 5724,
+      "byteOffset": 9060,
+      "target": 34962
+    },
+    {
+      "buffer": 0,
+      "byteLength": 3816,
+      "byteOffset": 14784,
+      "target": 34962
+    }
+  ],
+  "scenes": [
+    {
+      "name": "Root Scene",
+      "nodes": [
+        0
+      ],
+      "extras": {
+        "components": {
+          "shape": [
+            {
+              "shape": "box",
+              "halfExtents": {
+                "x": 0.06,
+                "y": 0.04,
+                "z": 0.08
+              },
+              "offset": {
+                "x": 0,
+                "y": 0.052671334114334445,
+                "z": 0.01001389278835843
+              },
+              "orientation": {
+                "x": 0,
+                "y": 0,
+                "z": 0,
+                "w": 1
+              }
+            },
+            {
+              "shape": "sphere",
+              "radius": 0.05,
+              "offset": {
+                "x": 0,
+                "y": 0.1287570519589527,
+                "z": 0.033095376412929145
+              },
+              "orientation": {
+                "x": 0,
+                "y": 0,
+                "z": 0,
+                "w": 1
+              }
+            },
+            {
+              "shape": "cylinder",
+              "radiusTop": 0.02,
+              "radiusBottom": 0.02,
+              "height": 0.030000000000000013,
+              "numSegments": 8,
+              "offset": {
+                "x": 0,
+                "y": 0.12657048237702667,
+                "z": 0.09010837508332667
+              },
+              "orientation": {
+                "x": 0.7071067811865476,
+                "y": 0,
+                "z": 0,
+                "w": 0.7071067811865475
+              }
+            }
+          ]
+        }
+      }
+    }
+  ],
+  "accessors": [
+    {
+      "componentType": 5123,
+      "type": "SCALAR",
+      "count": 1668,
+      "bufferView": 0,
+      "byteOffset": 0
+    },
+    {
+      "componentType": 5126,
+      "type": "VEC3",
+      "count": 477,
+      "bufferView": 1,
+      "byteOffset": 0,
+      "min": [
+        -0.0587991699576378,
+        0.0129461474716663,
+        -0.0740185976028442
+      ],
+      "max": [
+        0.0618169121444225,
+        0.173104390501976,
+        0.104356855154037
+      ]
+    },
+    {
+      "componentType": 5126,
+      "type": "VEC3",
+      "count": 477,
+      "bufferView": 2,
+      "byteOffset": 0
+    },
+    {
+      "componentType": 5126,
+      "type": "VEC2",
+      "count": 477,
+      "bufferView": 3,
+      "byteOffset": 0
+    }
+  ],
+  "images": [
+    {
+      "name": "DuckyMesh.fbm/Ducky.jpg",
+      "uri": "DuckyMesh.fbm/Ducky.jpg"
+    }
+  ],
+  "samplers": [
+    {}
+  ],
+  "textures": [
+    {
+      "name": "file4",
+      "sampler": 0,
+      "source": 0
+    }
+  ],
+  "materials": [
+    {
+      "name": "lambert2",
+      "alphaMode": "OPAQUE",
+      "extras": {
+        "fromFBX": {
+          "shadingModel": "Lambert",
+          "isTruePBR": false
+        }
+      },
+      "pbrMetallicRoughness": {
+        "baseColorTexture": {
+          "index": 0,
+          "texCoord": 0
+        },
+        "baseColorFactor": [
+          0.800000011920929,
+          0.800000011920929,
+          0.800000011920929,
+          1
+        ],
+        "metallicFactor": 0.200000002980232,
+        "roughnessFactor": 0.800000011920929
+      }
+    }
+  ],
+  "meshes": [
+    {
+      "name": "Ducky",
+      "primitives": [
+        {
+          "material": 0,
+          "mode": 4,
+          "attributes": {
+            "NORMAL": 2,
+            "POSITION": 1,
+            "TEXCOORD_0": 3
+          },
+          "indices": 0
+        }
+      ]
+    }
+  ],
+  "nodes": [
+    {
+      "name": "RootNode",
+      "translation": [
+        0,
+        0,
+        0
+      ],
+      "rotation": [
+        0,
+        0,
+        0,
+        1
+      ],
+      "scale": [
+        1,
+        1,
+        1
+      ],
+      "children": [
+        1
+      ]
+    },
+    {
+      "name": "Ducky",
+      "translation": [
+        0,
+        0,
+        0
+      ],
+      "rotation": [
+        0,
+        0,
+        0,
+        1
+      ],
+      "scale": [
+        1,
+        1,
+        1
+      ],
+      "mesh": 0
+    }
+  ]
+}
\ No newline at end of file
diff --git a/src/assets/interactables/duck/gltf/DuckyMesh.json b/src/assets/interactables/duck/gltf/DuckyMesh.json
new file mode 100644
index 0000000000000000000000000000000000000000..97b41944a2ca7e12e5e12cf4892d2534ef73e5b4
--- /dev/null
+++ b/src/assets/interactables/duck/gltf/DuckyMesh.json
@@ -0,0 +1,60 @@
+{
+  "scenes": {
+    "Root Scene": {
+      "shape": [
+        {
+          "shape": "box",
+          "halfExtents": {
+            "x": 0.06,
+            "y": 0.04,
+            "z": 0.08
+          },
+          "offset": {
+            "x": 0,
+            "y": 0.052671334114334445,
+            "z": 0.01001389278835843
+          },
+          "orientation": {
+            "x": 0,
+            "y": 0,
+            "z": 0,
+            "w": 1
+          }
+        },
+        {
+          "shape": "sphere",
+          "radius": 0.05,
+          "offset": {
+            "x": 0,
+            "y": 0.1287570519589527,
+            "z": 0.033095376412929145
+          },
+          "orientation": {
+            "x": 0,
+            "y": 0,
+            "z": 0,
+            "w": 1
+          }
+        },
+        {
+          "shape": "cylinder",
+          "radiusTop": 0.02,
+          "radiusBottom": 0.02,
+          "height": 0.030000000000000013,
+          "numSegments": 8,
+          "offset": {
+            "x": 0,
+            "y": 0.12657048237702667,
+            "z": 0.09010837508332667
+          },
+          "orientation": {
+            "x": 0.7071067811865476,
+            "y": 0,
+            "z": 0,
+            "w": 0.7071067811865475
+          }
+        }
+      ]
+    }
+  }
+}
diff --git a/src/assets/interactables/duck/gltf/buffer.bin b/src/assets/interactables/duck/gltf/buffer.bin
new file mode 100644
index 0000000000000000000000000000000000000000..d7ed323e11aef0c4b2045dd8f2ddd34154244b84
Binary files /dev/null and b/src/assets/interactables/duck/gltf/buffer.bin differ
diff --git a/src/assets/stylesheets/2d-hud.css b/src/assets/stylesheets/2d-hud.css
index a3509f308bd4638f231ef87b714b7bdb05b5dfcf..a50436114181d18997248dce77a3cc1d9500363f 100644
--- a/src/assets/stylesheets/2d-hud.css
+++ b/src/assets/stylesheets/2d-hud.css
@@ -38,6 +38,7 @@
   display: flex;
   align-items: center;
   justify-content: center;
+  z-index: 10;
 }
 
 :local(.panel) {
diff --git a/src/assets/stylesheets/audio.scss b/src/assets/stylesheets/audio.scss
index 67aac69962d861e993fb0e58e5dec9441536d2a9..d73a85eddc926c4b9a2f58c21fd4a97521822fd8 100644
--- a/src/assets/stylesheets/audio.scss
+++ b/src/assets/stylesheets/audio.scss
@@ -28,17 +28,29 @@
       @extend %rounded-border;
       @extend %default-font;
 
+      appearance: none;
+      -moz-appearance: none;
+      -webkit-appearance: none;
       background-color: black;
       padding: 6px;
+      padding-right: 30px;
       color: white;
       font-size: 1.1em;
       width: 90%;
     }
 
     &__mic-icon {
+      pointer-events: none;
       position: absolute;
-      left: 7.5%;
-      top: 10px;
+      left: 8%;
+      top: 9px;
+    }
+
+    &__dropdown-arrow {
+      pointer-events: none;
+      position: absolute;
+      right: 7.5%;
+      top: 16px;
     }
   }
 
@@ -50,42 +62,16 @@
     align-items: center;
     width: 100%;
 
-    &__mic {
-      position:relative;
-      width: 111px;
-      height: 111px;
-    }
-
-    &__mic_icon {
-      position: absolute;
-      top: 0;
-      left: 0;
-      z-index: 2;
-      min-width: 111px;
-      min-height: 111px;
-    }
-
-    &__speaker {
+    &__icon {
       position:relative;
       width: 111px;
       height: 111px;
     }
 
-    &__speaker_icon {
-      position: absolute;
+    &__icon-part {
+      position:absolute;
       top: 0;
       left: 0;
-      z-index: 2;
-      min-width: 111px;
-      min-height: 111px;
-    }
-
-    &__level {
-      position: absolute;
-      top: 0;
-      left: 0;
-      opacity: 1.0;
-      z-index: 1;
     }
   }
 
@@ -118,17 +104,26 @@
     @extend %top-subtitle;
   }
 
-  &__icon {
+  &__button-container {
     flex: 10;
     display: flex;
     justify-content: center;
     align-items: center;
     cursor: pointer;
+    width: 111px;
+    height: 111px;
+  }
+
+  &__button {
+    background: none;
+    border: none;
+    cursor: pointer;
   }
 
   &__next {
     @extend %bottom-button;
-    margin: auto;
-    flex: 1 1 20px;
+    padding-top: 0;
+    padding-bottom: 0;
+    flex: 1 1;
   }
 }
diff --git a/src/assets/stylesheets/entry.scss b/src/assets/stylesheets/entry.scss
index bdd20d1ee7af717ac0b2808b696c85fe49c8d775..abed31db312891d5d0d4c425b42310851df21553 100644
--- a/src/assets/stylesheets/entry.scss
+++ b/src/assets/stylesheets/entry.scss
@@ -20,28 +20,18 @@
   justify-content: center;
 
   &__screen-sharing {
-	font-size: 1.4em;
-	margin-left: 2.95em;
-	margin-top: 0.6em;
-  }
+    font-size: 1.4em;
+    margin-left: 2.95em;
+    margin-top: 0.6em;
 
-  &__screen-sharing-checkbox {
-	appearance: none;
-	-moz-appearance: none;
-	-webkit-appearance: none;
-	width: 2em;
-	height: 2em;
-	border: 3px solid white;
-	border-radius: 9px;
-	vertical-align: sub;
-	margin: 0 0.6em
+    &__checkbox {
+      @extend %checkbox;
+    }
+    &__checkbox:checked {
+      @extend %checkbox-checked;
+    }
   }
 
-  &__screen-sharing-checkbox:checked {
-	border: 9px double white;
-	outline: 9px solid white;
-	outline-offset: -18px;
-  }
 
   &__secondary {
     width: 100%;
@@ -58,6 +48,10 @@
   margin-top: 10px;
   margin-bottom: 10px;
   cursor: pointer;
+  background: none;
+  color: white;
+  border: none;
+  @extend %default-font;
 
   &__icon {
     flex: 1 1 90px;
diff --git a/src/assets/stylesheets/exited.scss b/src/assets/stylesheets/exited.scss
index 72959090e6cf5ed18d29c2750beb7bbb4280fcae..693d6d38798705979478930f0b175ea57aa6e183 100644
--- a/src/assets/stylesheets/exited.scss
+++ b/src/assets/stylesheets/exited.scss
@@ -1,4 +1,6 @@
 .exited-panel {
+  position: absolute;
+  color: white;
   background-color: black;
   width: 100%;
   height: 100%;
diff --git a/src/assets/stylesheets/profile.scss b/src/assets/stylesheets/profile.scss
index 000e974bbad3aec3760eb51e9863c78755a590fc..95f2caa2629d34e3f99fbab242f5cbf28b498038 100644
--- a/src/assets/stylesheets/profile.scss
+++ b/src/assets/stylesheets/profile.scss
@@ -41,6 +41,10 @@
     color: $grey-text;
   }
 
+  &__display-name-label {
+    font-size: 1.2em;
+    margin-right: 0.5em;
+  }
   &__form-field-text {
     @extend %rounded-border;
     @extend %default-font;
@@ -54,19 +58,34 @@
     margin: 0.5em 0;
   }
 
-  &__form-submit {
-    @extend %default-font;
-    border: none;
+  &__terms {
+    margin-bottom: 16px;
 
-    margin: 8px;
-    width: 100px;
-    line-height: 1.5em;
-    font-size: 1.0em;
+    &__checkbox {
+      @extend %checkbox;
+      vertical-align: unset;
+    }
+    &__checkbox:checked {
+      @extend %checkbox-checked;
+    }
 
-    background-color: transparent;
-    font-weight: bold;
-    color: white;
-    cursor: pointer;
+    &__text {
+      display: inline-block;
+      max-width: 20em;
+    }
+
+    &__link {
+      color: white;
+    }
+
+    &__link:visited {
+      color: grey;
+    }
+  }
+
+  &__form-submit {
+    @extend %bottom-button;
+    margin: 0;
   }
 }
 
@@ -87,12 +106,16 @@
     flex: 6 1 auto;
     font-size: 1.2em;
     line-height: 50px;
+    text-overflow: ellipsis;
+    overflow: hidden;
+    white-space: nowrap;
   }
 
   &__app_name {
     font-size: 1.8em;
     padding-right: 18px;
     line-height: 50px;
+    white-space: nowrap;
   }
 }
 
diff --git a/src/assets/stylesheets/shared.scss b/src/assets/stylesheets/shared.scss
index c2d4f013de5b853664b751400969602b356a53f9..f959943585bbfacf37791586f5485add3e524cd1 100644
--- a/src/assets/stylesheets/shared.scss
+++ b/src/assets/stylesheets/shared.scss
@@ -17,11 +17,17 @@ $darker-grey: rgba(64, 64, 64, 1.0);
 }
 
 %bottom-button {
+  @extend %default-font;
   font-size: 1em;
   font-weight: bold;
   margin-top: auto;
   margin-bottom: 30px;
   cursor: pointer;
+  border: 3px solid white;
+  border-radius: 14px;
+  padding: 12px;
+  background: none;
+  color: white;
 }
 
 %top-title {
@@ -42,3 +48,21 @@ $darker-grey: rgba(64, 64, 64, 1.0);
   border: none;
   font-size: 64pt;
 }
+
+%checkbox {
+  appearance: none;
+  -moz-appearance: none;
+  -webkit-appearance: none;
+  width: 2em;
+  height: 2em;
+  border: 3px solid white;
+  border-radius: 9px;
+  vertical-align: sub;
+  margin: 0 0.6em
+}
+
+%checkbox-checked {
+  border: 9px double white;
+  outline: 9px solid white;
+  outline-offset: -18px;
+}
diff --git a/src/assets/translations.data.json b/src/assets/translations.data.json
index 1757ec61cce5b97855750537a7812efe5349a57e..1b9844c8abf2dc7d9ed7c089f84923faa601fd96 100644
--- a/src/assets/translations.data.json
+++ b/src/assets/translations.data.json
@@ -14,8 +14,14 @@
     "entry.daydream-via-chrome": "Using Google Chrome",
     "entry.enable-screen-sharing": "Share my desktop",
     "profile.save": "SAVE",
+    "profile.display_name.label": "Display name:",
     "profile.display_name.validation_warning": "Alphanumerics and hyphens. At least 3 characters, no more than 32",
     "profile.header": "Your identity",
+    "profile.terms.prefix": "I confirm that I am over the age of 13 and agree to the",
+    "profile.terms.privacy": "privacy policy",
+    "profile.terms.conjunction": "and",
+    "profile.terms.tou": "terms of use",
+    "profile.terms.suffix": ".",
     "profile.avatar-selector.loading": "Loading Avatars...",
     "audio.title": "Test your audio",
     "audio.subtitle-desktop": "Confirm HMD speaker output",
@@ -26,7 +32,6 @@
     "audio.grant-subtitle": "Mic access needed to be heard by others",
     "audio.granted-title": "Mic permissions granted",
     "audio.granted-subtitle": "You can still mute yourself in-game",
-    "audio.grant-next": "  ",
     "audio.granted-next": "NEXT",
     "exit.subtitle": "Your session has ended.",
     "autoexit.title": "Auto-ending session in ",
diff --git a/src/avatar-selector.html b/src/avatar-selector.html
index 8496ef94d7f95ec90d29ae5c7e3c030c15659238..531a7e15f8ba2bbd479273d0af8332f3b3c836e1 100644
--- a/src/avatar-selector.html
+++ b/src/avatar-selector.html
@@ -3,6 +3,7 @@
 
 <head>
   <meta charset="utf-8">
+  <title>avatar selector</title>
   <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
   <% if(NODE_ENV === "production") { %>
     <script src="https://cdn.rawgit.com/brianpeiris/aframe/845825ae694449524c185c44a314d361eead4680/dist/aframe-master.min.js"></script>
diff --git a/src/behaviours/oculus-touch-joystick-dpad4.js b/src/behaviours/joystick-dpad4.js
similarity index 86%
rename from src/behaviours/oculus-touch-joystick-dpad4.js
rename to src/behaviours/joystick-dpad4.js
index bf397ba2fbda2357c7fdf1cfc0310c8aa1ba548c..d0cc1e2a87d1882346deb724d5ba14570d46b02c 100644
--- a/src/behaviours/oculus-touch-joystick-dpad4.js
+++ b/src/behaviours/joystick-dpad4.js
@@ -1,7 +1,7 @@
 import { angleTo4Direction } from "../utils/dpad";
 
 // @TODO specify 4 or 8 direction
-function oculus_touch_joystick_dpad4(el, outputPrefix) {
+function joystick_dpad4(el, outputPrefix) {
   this.angleToDirection = angleTo4Direction;
   this.outputPrefix = outputPrefix;
   this.centerRadius = 0.6;
@@ -11,7 +11,7 @@ function oculus_touch_joystick_dpad4(el, outputPrefix) {
   el.addEventListener("axismove", this.emitDPad4);
 }
 
-oculus_touch_joystick_dpad4.prototype = {
+joystick_dpad4.prototype = {
   emitDPad4: function(event) {
     const x = event.detail.axis[0];
     const y = event.detail.axis[1];
@@ -25,4 +25,4 @@ oculus_touch_joystick_dpad4.prototype = {
   }
 };
 
-export { oculus_touch_joystick_dpad4 };
+export default joystick_dpad4;
diff --git a/src/behaviours/vive-trackpad-dpad4.js b/src/behaviours/trackpad-dpad4.js
similarity index 93%
rename from src/behaviours/vive-trackpad-dpad4.js
rename to src/behaviours/trackpad-dpad4.js
index 99bdf8873d4cbcce3541691e6073af71c153db2b..b23ca1dbdb1f61ec6d99e9d2fab17ab4c96b0248 100644
--- a/src/behaviours/vive-trackpad-dpad4.js
+++ b/src/behaviours/trackpad-dpad4.js
@@ -1,6 +1,6 @@
 import { angleTo4Direction } from "../utils/dpad";
 
-function vive_trackpad_dpad4(el, outputPrefix) {
+function trackpad_dpad4(el, outputPrefix) {
   this.outputPrefix = outputPrefix;
   this.lastDirection = "";
   this.previous = "";
@@ -15,7 +15,7 @@ function vive_trackpad_dpad4(el, outputPrefix) {
   el.addEventListener("trackpadup", this.unpress);
 }
 
-vive_trackpad_dpad4.prototype = {
+trackpad_dpad4.prototype = {
   press: function() {
     this.pressed = true;
   },
@@ -51,4 +51,4 @@ vive_trackpad_dpad4.prototype = {
   }
 };
 
-export { vive_trackpad_dpad4 };
+export default trackpad_dpad4;
diff --git a/src/components/animated-robot-hands.js b/src/components/animated-robot-hands.js
deleted file mode 100644
index 1b26402848a8cb641560d76307708c4d77d53352..0000000000000000000000000000000000000000
--- a/src/components/animated-robot-hands.js
+++ /dev/null
@@ -1,94 +0,0 @@
-// Global THREE, AFRAME
-const POSES = {
-  open: "allOpen",
-  thumbDown: "thumbDown",
-  indexDown: "indexDown",
-  mrpDown: "mrpDown",
-  thumbUp: "thumbsUp",
-  point: "point",
-  fist: "allGrip",
-  pinch: "pinch"
-};
-
-// TODO: When we have analog values of index-finger triggers or middle-finger grips,
-//       it would be nice to animate the hands proportionally to those analog values.
-AFRAME.registerComponent("animated-robot-hands", {
-  dependencies: ["animation-mixer"],
-  schema: {
-    leftHand: { type: "selector", default: "#player-left-controller" },
-    rightHand: { type: "selector", default: "#player-right-controller" }
-  },
-
-  init: function() {
-    this.playAnimation = this.playAnimation.bind(this);
-
-    this.mixer = this.el.components["animation-mixer"].mixer;
-
-    const object3DMap = this.el.object3DMap;
-    const rootObj = object3DMap.mesh || object3DMap.scene;
-    this.clipActionObject = rootObj.parent;
-
-    // Set hands to open pose because the bind pose is funky dues
-    // to the workaround for FBX2glTF animations.
-    this.openL = this.mixer.clipAction(POSES.open + "_L", this.clipActionObject);
-    this.openR = this.mixer.clipAction(POSES.open + "_R", this.clipActionObject);
-    this.openL.play();
-    this.openR.play();
-  },
-
-  play: function() {
-    this.data.leftHand.addEventListener("hand-pose", this.playAnimation);
-    this.data.rightHand.addEventListener("hand-pose", this.playAnimation);
-  },
-
-  pause: function() {
-    this.data.leftHand.removeEventListener("hand-pose", this.playAnimation);
-    this.data.rightHand.removeEventListener("hand-pose", this.playAnimation);
-  },
-
-  // Animate from pose to pose.
-  // TODO: Transition from current pose (which may be BETWEEN two other poses)
-  //       to the target pose, rather than stopping previous actions altogether.
-  playAnimation: function(evt) {
-    const isLeft = evt.target === this.data.leftHand;
-    // Stop the initial animations we started when the model loaded.
-    if (!this.openLStopped && isLeft) {
-      this.openL.stop();
-      this.openLStopped = true;
-    } else if (!this.openRStopped && !isLeft) {
-      this.openR.stop();
-      this.openRStopped = true;
-    }
-
-    const { current, previous } = evt.detail;
-    const mixer = this.mixer;
-    const suffix = isLeft ? "_L" : "_R";
-    const prevPose = POSES[previous] + suffix;
-    const currPose = POSES[current] + suffix;
-
-    // STOP previous actions playing for this hand.
-    if (this["pose" + suffix + "_to"] !== undefined) {
-      this["pose" + suffix + "_to"].stop();
-    }
-    if (this["pose" + suffix + "_from"] !== undefined) {
-      this["pose" + suffix + "_from"].stop();
-    }
-
-    const duration = 0.065;
-    //    console.log(
-    //      `Animating ${isLeft ? "left" : "right"} hand from ${prevPose} to ${currPose} over ${duration} seconds.`
-    //    );
-    const from = mixer.clipAction(prevPose, this.clipActionObject);
-    const to = mixer.clipAction(currPose, this.clipActionObject);
-    from.fadeOut(duration);
-    to.fadeIn(duration);
-    to.play();
-    from.play();
-    // Update the mixer slightly to prevent one frame of the default pose
-    // from appearing. TODO: Find out why that happens
-    this.mixer.update(0.001);
-
-    this["pose" + suffix + "_to"] = to;
-    this["pose" + suffix + "_from"] = from;
-  }
-});
diff --git a/src/components/audio-feedback.js b/src/components/audio-feedback.js
index d4bdf5792e79cc1eb3191a0d042f59e89308956d..a72ec196ece7d4f66f5cc0e85b7f9a1edaeff916 100644
--- a/src/components/audio-feedback.js
+++ b/src/components/audio-feedback.js
@@ -1,20 +1,13 @@
 AFRAME.registerComponent("networked-audio-analyser", {
   schema: {},
   async init() {
-    const networkedEl = await NAF.utils.getNetworkedEntity(this.el);
-    const ownerId = networkedEl.components.networked.data.owner;
-
-    const stream = await NAF.connection.adapter.getMediaStream(ownerId);
-
-    if (!stream) {
-      return;
-    }
-
-    const ctx = THREE.AudioContext.getContext();
-    const source = ctx.createMediaStreamSource(stream);
-    this.analyser = ctx.createAnalyser();
-    this.levels = new Uint8Array(this.analyser.frequencyBinCount);
-    source.connect(this.analyser);
+    this.el.addEventListener("sound-source-set", event => {
+      const ctx = THREE.AudioContext.getContext();
+      this.analyser = ctx.createAnalyser();
+      this.analyser.fftSize = 32;
+      this.levels = new Uint8Array(this.analyser.frequencyBinCount);
+      event.detail.soundSource.connect(this.analyser);
+    });
   },
 
   tick: function() {
diff --git a/src/components/hand-poses.js b/src/components/hand-poses.js
new file mode 100644
index 0000000000000000000000000000000000000000..16d1f1479af6f4f4e17963084e6e135ecb82b942
--- /dev/null
+++ b/src/components/hand-poses.js
@@ -0,0 +1,75 @@
+const POSES = {
+  open: "allOpen",
+  thumbDown: "thumbDown",
+  indexDown: "indexDown",
+  mrpDown: "mrpDown",
+  thumbUp: "thumbsUp",
+  point: "point",
+  fist: "allGrip",
+  pinch: "pinch"
+};
+
+const NETWORK_POSES = ["allOpen", "thumbDown", "indexDown", "mrpDown", "thumbsUp", "point", "allGrip", "pinch"];
+
+AFRAME.registerComponent("hand-pose", {
+  multiple: true,
+  schema: {
+    pose: { default: 0 }
+  },
+
+  init() {
+    this.animatePose = this.animatePose.bind(this);
+    this.mixer = this.el.components["animation-mixer"];
+    const object3DMap = this.mixer.el.object3DMap;
+    const rootObj = object3DMap.mesh || object3DMap.scene;
+    this.clipActionObject = rootObj.parent;
+    const suffix = this.id == "left" ? "_L" : "_R";
+    this.from = this.to = this.mixer.mixer.clipAction(POSES.open + suffix, this.clipActionObject);
+    this.from.play();
+  },
+
+  update(oldData) {
+    if (oldData.pose != this.data.pose) {
+      this.animatePose(NETWORK_POSES[oldData.pose || 0], NETWORK_POSES[this.data.pose]);
+    }
+  },
+
+  animatePose(prev, curr) {
+    this.from.stop();
+    this.to.stop();
+
+    const duration = 0.065;
+    const suffix = this.id == "left" ? "_L" : "_R";
+    this.from = this.mixer.mixer.clipAction(prev + suffix, this.clipActionObject);
+    this.to = this.mixer.mixer.clipAction(curr + suffix, this.clipActionObject);
+
+    this.from.fadeOut(duration);
+    this.to.fadeIn(duration);
+    this.to.play();
+    this.from.play();
+
+    this.mixer.mixer.update(0.001);
+  }
+});
+
+AFRAME.registerComponent("hand-pose-controller", {
+  multiple: true,
+  schema: {
+    eventSrc: { type: "selector" }
+  },
+  init: function() {
+    this.setHandPose = this.setHandPose.bind(this);
+  },
+
+  play: function() {
+    this.data.eventSrc.addEventListener("hand-pose", this.setHandPose);
+  },
+
+  pause: function() {
+    this.data.eventSrc.removeEventListener("hand-pose", this.setHandPose);
+  },
+
+  setHandPose: function(evt) {
+    this.el.setAttribute(`hand-pose__${this.id}`, "pose", NETWORK_POSES.indexOf(POSES[evt.detail.current]));
+  }
+});
diff --git a/src/components/hud-controller.js b/src/components/hud-controller.js
new file mode 100644
index 0000000000000000000000000000000000000000..4ee274f34ae9daa6f24c8e414eee824b845c49db
--- /dev/null
+++ b/src/components/hud-controller.js
@@ -0,0 +1,77 @@
+import { AppModes } from "../systems/app-mode.js";
+
+const TWOPI = Math.PI * 2;
+function deltaAngle(a, b) {
+  const p = Math.abs(b - a) % TWOPI;
+  return p > Math.PI ? TWOPI - p : p;
+}
+
+/**
+ * Positions the HUD and toggles app mode based on where the user is looking
+ */
+AFRAME.registerComponent("hud-controller", {
+  schema: {
+    head: { type: "selector" },
+    offset: { default: 0.7 }, // distance from hud above head,
+    lookCutoff: { default: 20 }, // angle at which the hud should be "on",
+    animRange: { default: 30 }, // degrees over which to animate the hud into view
+    yawCutoff: { default: 50 } // yaw degrees at wich the hud should reoirent even if the user is looking up
+  },
+  init() {
+    this.isYLocked = false;
+    this.lockedHeadPositionY = 0;
+  },
+
+  pause() {
+    // TODO: this assumes full control over current app mode reguardless of what else might be manipulating it, this is obviously wrong
+    const AppModeSystem = this.el.sceneEl.systems["app-mode"];
+    AppModeSystem.setMode(AppModes.DEFAULT);
+  },
+
+  tick() {
+    const hud = this.el.object3D;
+    const head = this.data.head.object3D;
+    const sceneEl = this.el.sceneEl;
+
+    const { offset, lookCutoff, animRange, yawCutoff } = this.data;
+
+    const pitch = head.rotation.x * THREE.Math.RAD2DEG;
+    const yawDif = deltaAngle(head.rotation.y, hud.rotation.y) * THREE.Math.RAD2DEG;
+
+    // Reorient the hud only if the user is looking away from the hud, for right now this arbitrarily means the hud is 1/3 way animated away
+    // TODO: come up with better huristics for this that maybe account for the user turning away from the hud "too far", also animate the position so that it doesnt just snap.
+    if (yawDif >= yawCutoff || pitch < lookCutoff - animRange / 3) {
+      const lookDir = new THREE.Vector3(0, 0, -1);
+      lookDir.applyQuaternion(head.quaternion);
+      lookDir.add(head.position);
+      hud.position.x = lookDir.x;
+      hud.position.z = lookDir.z;
+      hud.setRotationFromEuler(new THREE.Euler(0, head.rotation.y, 0));
+    }
+
+    // animate the hud into place over animRange degrees as the user aproaches the lookCutoff angle
+    const t = 1 - THREE.Math.clamp(lookCutoff - pitch, 0, animRange) / animRange;
+
+    // Lock the hud in place relative to a known head position so it doesn't bob up and down
+    // with the user's head
+    if (!this.isYLocked && t === 1) {
+      this.lockedHeadPositionY = head.position.y;
+    }
+    const EPSILON = 0.001;
+    this.isYLocked = t > 1 - EPSILON;
+
+    hud.position.y = (this.isYLocked ? this.lockedHeadPositionY : head.position.y) + offset + (1 - t) * offset;
+    hud.rotation.x = (1 - t) * THREE.Math.DEG2RAD * 90;
+
+    // update the app mode when the HUD locks on or off
+    // TODO: this assumes full control over current app mode reguardless of what else might be manipulating it, this is obviously wrong
+    const AppModeSystem = sceneEl.systems["app-mode"];
+    if (pitch > lookCutoff && AppModeSystem.mode !== AppModes.HUD) {
+      AppModeSystem.setMode(AppModes.HUD);
+      sceneEl.renderer.sortObjects = true;
+    } else if (pitch < lookCutoff && AppModeSystem.mode === AppModes.HUD) {
+      AppModeSystem.setMode(AppModes.DEFAULT);
+      sceneEl.renderer.sortObjects = false;
+    }
+  }
+});
diff --git a/src/components/nav-mesh-helper.js b/src/components/nav-mesh-helper.js
new file mode 100644
index 0000000000000000000000000000000000000000..5c1be4ce96946dddb6ba98590dc7da27d403ab10
--- /dev/null
+++ b/src/components/nav-mesh-helper.js
@@ -0,0 +1,16 @@
+AFRAME.registerComponent("nav-mesh-helper", {
+  schema: {
+    teleportControls: { type: "selectorAll", default: "[teleport-controls]" }
+  },
+
+  init: function() {
+    const teleportControls = this.data.teleportControls;
+    this.el.addEventListener("bundleloaded", () => {
+      if (!teleportControls) return;
+
+      for (let i = 0; i < teleportControls.length; i++) {
+        teleportControls[i].components["teleport-controls"].queryCollisionEntities();
+      }
+    });
+  }
+});
diff --git a/src/components/super-cursor.js b/src/components/super-cursor.js
index b8daee493abcbfb543db553538c5b968748da381..ee70e960663144709f17108d70a2378a6bc9f935 100644
--- a/src/components/super-cursor.js
+++ b/src/components/super-cursor.js
@@ -92,7 +92,7 @@ AFRAME.registerComponent("super-cursor", {
       this.data.cursor.object3D.position.copy(this.point);
     }
 
-    this.isInteractable = intersection && intersection.object.el.className === "interactable";
+    this.isInteractable = intersection && this._isInteractable(intersection.object.el);
 
     if ((this.isGrabbing || this.isInteractable) && !this.wasIntersecting) {
       this.wasIntersecting = true;
@@ -103,6 +103,13 @@ AFRAME.registerComponent("super-cursor", {
     }
   },
 
+  _isInteractable: function(el) {
+    return (
+      el.className === "interactable" ||
+      (el.parentNode && el.parentNode != el.sceneEl && this._isInteractable(el.parentNode))
+    );
+  },
+
   _handleMouseDown: function() {
     if (this.isInteractable) {
       const lookControls = this.data.camera.components["look-controls"];
@@ -122,7 +129,19 @@ AFRAME.registerComponent("super-cursor", {
   },
 
   _handleWheel: function(e) {
-    if (this.isGrabbing) this.currentDistanceMod += e.deltaY / 10;
+    if (this.isGrabbing) {
+      switch (e.deltaMode) {
+        case e.DOM_DELTA_PIXEL:
+          this.currentDistanceMod += e.deltaY / 500;
+          break;
+        case e.DOM_DELTA_LINE:
+          this.currentDistanceMod += e.deltaY / 10;
+          break;
+        case e.DOM_DELTA_PAGE:
+          this.currentDistanceMod += e.deltaY / 2;
+          break;
+      }
+    }
   },
 
   _handleEnterVR: function() {
diff --git a/src/components/virtual-gamepad-controls.css b/src/components/virtual-gamepad-controls.css
index d3e36e2fa243e0d9e35e693224cbd235420a0702..572e6169f6a29c911d0fa89e37382b10838da3c0 100644
--- a/src/components/virtual-gamepad-controls.css
+++ b/src/components/virtual-gamepad-controls.css
@@ -1,6 +1,6 @@
 :local(.touchZone) {
   position: absolute;
-  top: 0;
+  height: 20vh;
   bottom: 0;
 }
 
@@ -13,7 +13,3 @@
   left: 50%;
   right: 0;
 }
-
-:local(.touchZone) .nipple {
-  margin: 5vh 5vw;
-}
diff --git a/src/components/virtual-gamepad-controls.js b/src/components/virtual-gamepad-controls.js
index d70219bf1e6374fefc6d6daaeb9e8e10bfc27fb8..f92b7d4534f8e45e499edf6bcf1345f9e0f33374 100644
--- a/src/components/virtual-gamepad-controls.js
+++ b/src/components/virtual-gamepad-controls.js
@@ -16,29 +16,42 @@ AFRAME.registerComponent("virtual-gamepad-controls", {
 
     const leftStick = nipplejs.create({
       zone: leftTouchZone,
-      mode: "static",
       color: "white",
-      position: { left: "50px", bottom: "50px" }
+      fadeTime: 0
     });
 
     const rightStick = nipplejs.create({
       zone: rightTouchZone,
-      mode: "static",
       color: "white",
-      position: { right: "50px", bottom: "50px" }
+      fadeTime: 0
     });
 
-    this.onJoystickChanged = this.onJoystickChanged.bind(this);
+    this.onMoveJoystickChanged = this.onMoveJoystickChanged.bind(this);
+    this.onMoveJoystickEnd = this.onMoveJoystickEnd.bind(this);
+    this.onLookJoystickChanged = this.onLookJoystickChanged.bind(this);
+    this.onLookJoystickEnd = this.onLookJoystickEnd.bind(this);
 
-    rightStick.on("move end", this.onJoystickChanged);
-    leftStick.on("move end", this.onJoystickChanged);
+    leftStick.on("move", this.onMoveJoystickChanged);
+    leftStick.on("end", this.onMoveJoystickEnd);
+
+    rightStick.on("move", this.onLookJoystickChanged);
+    rightStick.on("end", this.onLookJoystickEnd);
 
     this.leftTouchZone = leftTouchZone;
     this.rightTouchZone = rightTouchZone;
     this.leftStick = leftStick;
     this.rightStick = rightStick;
 
-    this.yaw = 0;
+    this.inVr = false;
+    this.moving = false;
+    this.rotating = false;
+
+    this.moveEvent = {
+      axis: [0, 0]
+    };
+    this.rotateYEvent = {
+      value: 0
+    };
 
     this.onEnterVr = this.onEnterVr.bind(this);
     this.onExitVr = this.onExitVr.bind(this);
@@ -46,39 +59,59 @@ AFRAME.registerComponent("virtual-gamepad-controls", {
     this.el.sceneEl.addEventListener("exit-vr", this.onExitVr);
   },
 
-  onJoystickChanged(event, joystick) {
-    if (event.target.id === this.leftStick.id) {
-      if (event.type === "move") {
-        const angle = joystick.angle.radian;
-        const force = joystick.force < 1 ? joystick.force : 1;
-        const x = Math.cos(angle) * force;
-        const z = Math.sin(angle) * force;
-        this.el.sceneEl.emit("move", { axis: [x, z] });
-      } else {
-        this.el.sceneEl.emit("move", { axis: [0, 0] });
+  onMoveJoystickChanged(event, joystick) {
+    const angle = joystick.angle.radian;
+    const force = joystick.force < 1 ? joystick.force : 1;
+    const x = Math.cos(angle) * force;
+    const z = Math.sin(angle) * force;
+    this.moving = true;
+    this.moveEvent.axis[0] = x;
+    this.moveEvent.axis[1] = z;
+  },
+
+  onMoveJoystickEnd() {
+    this.moving = false;
+    this.moveEvent.axis[0] = 0;
+    this.moveEvent.axis[1] = 0;
+    this.el.sceneEl.emit("move", this.moveEvent);
+  },
+
+  onLookJoystickChanged(event, joystick) {
+    // Set pitch and yaw angles on right stick move
+    const angle = joystick.angle.radian;
+    const force = joystick.force < 1 ? joystick.force : 1;
+    this.rotating = true;
+    this.rotateYEvent.value = Math.cos(angle) * force;
+  },
+
+  onLookJoystickEnd() {
+    this.rotating = false;
+    this.rotateYEvent.value = 0;
+    this.el.sceneEl.emit("rotateY", this.rotateYEvent);
+  },
+
+  tick() {
+    if (!this.inVr) {
+      if (this.moving) {
+        this.el.sceneEl.emit("move", this.moveEvent);
       }
-    } else {
-      if (event.type === "move") {
-        // Set pitch and yaw angles on right stick move
-        const angle = joystick.angle.radian;
-        const force = joystick.force < 1 ? joystick.force : 1;
-        this.yaw = Math.cos(angle) * force;
-        this.el.sceneEl.emit("rotateY", { value: this.yaw });
-      } else {
-        this.yaw = 0;
-        this.el.sceneEl.emit("rotateY", { value: this.yaw });
+
+      if (this.rotating) {
+        this.el.sceneEl.emit("rotateY", this.rotateYEvent);
       }
     }
   },
 
   onEnterVr() {
     // Hide the joystick controls
+    this.inVr = true;
     this.leftTouchZone.style.display = "none";
     this.rightTouchZone.style.display = "none";
   },
 
   onExitVr() {
     // Show the joystick controls
+    this.inVr = false;
     this.leftTouchZone.style.display = "block";
     this.rightTouchZone.style.display = "block";
   },
diff --git a/src/gltf-component-mappings.js b/src/gltf-component-mappings.js
index 06e81969a0f72d0d3a90ae7b247f28bf68fa2aaa..2166e7a62a186c9caa1ce3da8f39b5cafa4fff31 100644
--- a/src/gltf-component-mappings.js
+++ b/src/gltf-component-mappings.js
@@ -2,5 +2,6 @@ import "./components/gltf-model-plus";
 
 AFRAME.GLTFModelPlus.registerComponent("scale-audio-feedback", "scale-audio-feedback");
 AFRAME.GLTFModelPlus.registerComponent("loop-animation", "loop-animation");
+AFRAME.GLTFModelPlus.registerComponent("shape", "shape");
 AFRAME.GLTFModelPlus.registerComponent("visible", "visible");
 AFRAME.GLTFModelPlus.registerComponent("nav-mesh", "nav-mesh");
diff --git a/src/hub.html b/src/hub.html
index 293fa12ea82ba46d485e3f0c9b4a38ff8856c754..e7ca7655ff31d81da8529db96cfecdf85f5c66a1 100644
--- a/src/hub.html
+++ b/src/hub.html
@@ -3,10 +3,11 @@
 
 <head>
     <meta charset="utf-8">
-    <title>moz://a duck</title>
-
     <meta name="viewport" content="width=device-width, initial-scale=1">
     <meta http-equiv="origin-trial" data-feature="WebVR (For Chrome M62+)" data-expires="<%= ORIGIN_TRIAL_EXPIRES %>" content="<%= ORIGIN_TRIAL_TOKEN %>">
+    <title>moz://a duck</title>
+    <link href="https://fonts.googleapis.com/css?family=Zilla+Slab:300,300i,400,400i,700" rel="stylesheet">
+
     <% if(NODE_ENV === "production") { %>
         <script src="https://cdn.rawgit.com/brianpeiris/aframe/845825ae694449524c185c44a314d361eead4680/dist/aframe-master.min.js"></script>
     <% } else { %>
@@ -46,7 +47,7 @@
             <a-asset-item id="watch-model" response-type="arraybuffer" src="./assets/hud/watch.glb"></a-asset-item>
             <a-asset-item id="interactable-duck" response-type="arraybuffer" src="./assets/interactables/duck/DuckyMesh.glb"></a-asset-item>
 
-            <img id="water-normal-map" src="./assets/waternormals.jpg"></a-asset-item>
+            <img id="water-normal-map" src="./assets/waternormals.jpg">
 
             <!-- Templates -->
 
@@ -64,19 +65,19 @@
 
                     <a-entity class="model" gltf-model-plus="inflate: true">
                         <template data-selector=".RootScene">
-                            <a-entity ik-controller animation-mixer space-invader-mesh="meshSelector: .Bot_Skinned"></a-entity>
+                            <a-entity ik-controller hand-pose__left hand-pose__right animation-mixer space-invader-mesh="meshSelector: .Bot_Skinned"></a-entity>
                         </template>
 
                         <template data-selector=".Neck">
-                             <a-entity>
-                                 <a-entity
-                                    class="nametag"
-                                    billboard
-                                    text="side: double; align: center; color: #ddd"
-                                    position="0 1 0"
-                                    scale="6 6 6"
-                                ></a-entity>
-                             </a-entity>
+                            <a-entity>
+                                <a-entity
+                                   class="nametag"
+                                   billboard
+                                   text="side: double; align: center; color: #ddd"
+                                   position="0 1 0"
+                                   scale="6 6 6"
+                               ></a-entity>
+                            </a-entity>
                         </template>
 
                         <template data-selector=".Chest">
@@ -113,11 +114,11 @@
 
             <template id="interactable-template">
                 <a-entity
-                    gltf-model-plus="src: #interactable-duck"
+                    gltf-model-plus="src: #interactable-duck; inflate: true;"
                     scale="2 2 2"
                     class="interactable" 
                     super-networked-interactable="counter: #counter; mass: 5;"
-                    body="type: dynamic; mass: 5; shape: box;"
+                    body="type: dynamic; shape: none; mass: 5;"
                     grabbable
                     stretchable="useWorldPosition: true;"
                 ></a-entity>
@@ -154,7 +155,7 @@
         >
             <a-sphere
                 id="3d-cursor"
-                radius=0.02
+                radius="0.02"
                 static-body="shape: sphere;"
                 mixin="super-hands"
                 segments-height="9"
@@ -165,7 +166,7 @@
         <!-- Player Rig -->
         <a-entity
             id="player-rig"
-            networked="template: #remote-avatar-template; attachLocalTemplate: false;"
+            networked="template: #remote-avatar-template; attachTemplateToLocal: false;"
             spawn-controller="radius: 4;"
             wasd-to-analog2d
             character-controller="pivot: #player-camera"
@@ -211,8 +212,6 @@
                 haptic-feedback
             ></a-entity>
 
-
-
             <a-entity
                 id="player-right-controller"
                 class="right-controller"
@@ -237,8 +236,11 @@
                 <template data-selector=".RootScene">
                     <a-entity
                         ik-controller
-                        animated-robot-hands
                         animation-mixer
+                        hand-pose__left
+                        hand-pose__right
+                        hand-pose-controller__left="eventSrc:#player-left-controller"
+                        hand-pose-controller__right="eventSrc:#player-right-controller"
                     ></a-entity>
                 </template>
 
@@ -285,9 +287,14 @@
         ></a-entity>
 
         <!-- Environment -->
-        <a-entity id="environment-root" position="0 0 0"></a-entity>
+        <a-entity 
+            id="environment-root" 
+            nav-mesh-helper
+            static-body="shape: none;"
+            class="collidable"
+        ></a-entity>
 
-        <a-entity id="skybox"
+        <a-entity
             id="skybox"
             scale="8000 8000 8000"
             skybox="azimuth:0.280; inclination:0.440"
@@ -304,23 +311,6 @@
             xr="ar: false"
         ></a-entity>
 
-        <a-cylinder
-            position="0 0.45 0"
-            material="visible: false"
-            height="1" radius="3.1"
-            segments-radial="12"
-            static-body
-            class="collidable"
-        ></a-cylinder>
-
-        <a-plane 
-            material="visible: false" 
-            rotation="-90 0 0" 
-            height="35" 
-            width="35" 
-            static-body 
-            class="collidable"
-        ></a-plane> 
     </a-scene>
 
     <div id="ui-root"></div>
diff --git a/src/hub.js b/src/hub.js
index 1db136698165309ced35534b3b735f96e61169e0..0489cb38e9afe45a50547030d2eba8eeacb1856e 100644
--- a/src/hub.js
+++ b/src/hub.js
@@ -1,5 +1,8 @@
 import "./assets/stylesheets/hub.scss";
+import moment from "moment-timezone";
+import uuid from "uuid/v4";
 import queryString from "query-string";
+import { Socket } from "phoenix";
 
 import { patchWebGLRenderingContext } from "./utils/webgl";
 patchWebGLRenderingContext();
@@ -15,8 +18,8 @@ import "aframe-rounded";
 import "webrtc-adapter";
 import "aframe-slice9-component";
 
-import { vive_trackpad_dpad4 } from "./behaviours/vive-trackpad-dpad4";
-import { oculus_touch_joystick_dpad4 } from "./behaviours/oculus-touch-joystick-dpad4";
+import trackpad_dpad4 from "./behaviours/trackpad-dpad4";
+import joystick_dpad4 from "./behaviours/joystick-dpad4";
 import { PressedMove } from "./activators/pressedmove";
 import { ReverseY } from "./activators/reversey";
 import "./activators/shortpress";
@@ -38,18 +41,20 @@ import "./components/water";
 import "./components/skybox";
 import "./components/layers";
 import "./components/spawn-controller";
-import "./components/animated-robot-hands";
 import "./components/hide-when-quality";
 import "./components/player-info";
 import "./components/debug";
 import "./components/animation-mixer";
 import "./components/loop-animation";
+import "./components/hand-poses";
 import "./components/gltf-model-plus";
 import "./components/gltf-bundle";
+import "./components/hud-controller";
 
 import ReactDOM from "react-dom";
 import React from "react";
 import UIRoot from "./react-components/ui-root";
+import HubChannel from "./utils/hub-channel";
 
 import "./systems/personal-space-bubble";
 import "./systems/app-mode";
@@ -79,12 +84,14 @@ import "./components/super-spawner";
 import "./components/super-cursor";
 import "./components/event-repeater";
 
+import "./components/nav-mesh-helper";
+
 import registerNetworkSchemas from "./network-schemas";
 import { inGameActions, config as inputConfig } from "./input-mappings";
 import registerTelemetry from "./telemetry";
 import Store from "./storage/store";
 
-import { generateDefaultProfile } from "./utils/identity.js";
+import { generateDefaultProfile, generateRandomName } from "./utils/identity.js";
 import { getAvailableVREntryTypes } from "./utils/vr-caps-detect.js";
 import ConcurrentLoadDetector from "./utils/concurrent-load-detector.js";
 
@@ -96,21 +103,28 @@ function qsTruthy(param) {
 
 registerTelemetry();
 
-AFRAME.registerInputBehaviour("vive_trackpad_dpad4", vive_trackpad_dpad4);
-AFRAME.registerInputBehaviour("oculus_touch_joystick_dpad4", oculus_touch_joystick_dpad4);
+AFRAME.registerInputBehaviour("trackpad_dpad4", trackpad_dpad4);
+AFRAME.registerInputBehaviour("joystick_dpad4", joystick_dpad4);
 AFRAME.registerInputActivator("pressedmove", PressedMove);
 AFRAME.registerInputActivator("reverseY", ReverseY);
 AFRAME.registerInputMappings(inputConfig, true);
 
 const store = new Store();
 const concurrentLoadDetector = new ConcurrentLoadDetector();
+const hubChannel = new HubChannel(store);
 
 concurrentLoadDetector.start();
 
 // Always layer in any new default profile bits
 store.update({ profile: { ...generateDefaultProfile(), ...(store.state.profile || {}) } });
 
+// Regenerate name to encourage users to change it.
+if (!store.state.profile.has_changed_name) {
+  store.update({ profile: { display_name: generateRandomName() } });
+}
+
 async function exitScene() {
+  hubChannel.disconnect();
   const scene = document.querySelector("a-scene");
   scene.renderer.animate(null); // Stop animation loop, TODO A-Frame should do this
   document.body.removeChild(scene);
@@ -148,7 +162,7 @@ async function enterScene(mediaStream, enterInVR, janusRoomId) {
     scene.setAttribute("stats", true);
   }
 
-  if (isMobile || qsTruthy(qs.mobile)) {
+  if (isMobile || qsTruthy("mobile")) {
     playerRig.setAttribute("virtual-gamepad-controls", {});
   }
 
@@ -184,6 +198,12 @@ async function enterScene(mediaStream, enterInVR, janusRoomId) {
   });
 
   if (!qsTruthy("offline")) {
+    document.body.addEventListener("connected", () => {
+      hubChannel.sendEntryEvent().then(() => {
+        store.update({ lastEnteredAt: moment().toJSON() });
+      });
+    });
+
     scene.components["networked-scene"].connect();
 
     if (mediaStream) {
@@ -207,15 +227,14 @@ async function enterScene(mediaStream, enterInVR, janusRoomId) {
   }
 }
 
-function mountUI(scene) {
+function mountUI(scene, props = {}) {
   const disableAutoExitOnConcurrentLoad = qsTruthy("allow_multi");
   const forcedVREntryType = qs.vr_entry_type || null;
   const enableScreenSharing = qsTruthy("enable_screen_sharing");
   const htmlPrefix = document.body.dataset.htmlPrefix || "";
+  const showProfileEntry = !store.state.profile.has_changed_name;
 
-  // TODO: Refactor to avoid using return value
-  /* eslint-disable react/no-render-return-value */
-  const uiRoot = ReactDOM.render(
+  ReactDOM.render(
     <UIRoot
       {...{
         scene,
@@ -226,14 +245,13 @@ function mountUI(scene) {
         forcedVREntryType,
         enableScreenSharing,
         store,
-        htmlPrefix
+        htmlPrefix,
+        showProfileEntry,
+        ...props
       }}
     />,
     document.getElementById("ui-root")
   );
-  /* eslint-enable react/no-render-return-value */
-
-  return uiRoot;
 }
 
 const onReady = async () => {
@@ -243,26 +261,31 @@ const onReady = async () => {
 
   registerNetworkSchemas();
 
-  const uiRoot = mountUI(scene);
+  mountUI(scene);
+
+  let modifiedProps = {};
+  const remountUI = props => {
+    modifiedProps = { ...modifiedProps, ...props };
+    mountUI(scene, modifiedProps);
+  };
 
   getAvailableVREntryTypes().then(availableVREntryTypes => {
-    uiRoot.setState({ availableVREntryTypes });
-    uiRoot.handleForcedVREntryType();
+    remountUI({ availableVREntryTypes });
   });
 
   const environmentRoot = document.querySelector("#environment-root");
 
   const initialEnvironmentEl = document.createElement("a-entity");
   initialEnvironmentEl.addEventListener("bundleloaded", () => {
-    uiRoot.setState({ initialEnvironmentLoaded: true });
-    // Wait a tick so that the environments actually render.
-    setTimeout(() => scene.renderer.animate(null));
+    remountUI({ initialEnvironmentLoaded: true });
+    // Wait a tick plus some margin so that the environments actually render.
+    setTimeout(() => scene.renderer.animate(null), 100);
   });
   environmentRoot.appendChild(initialEnvironmentEl);
 
   if (qs.room) {
     // If ?room is set, this is `yarn start`, so just use a default environment and query string room.
-    uiRoot.setState({ janusRoomId: qs.room && !isNaN(parseInt(qs.room)) ? parseInt(qs.room) : 1 });
+    remountUI({ janusRoomId: qs.room && !isNaN(parseInt(qs.room)) ? parseInt(qs.room) : 1 });
     initialEnvironmentEl.setAttribute("gltf-bundle", {
       src: "https://asset-bundles-prod.reticulum.io/rooms/meetingroom/MeetingRoom.bundle.json"
       // src: "https://asset-bundles-prod.reticulum.io/rooms/theater/TheaterMeshes.bundle.json"
@@ -272,15 +295,32 @@ const onReady = async () => {
     return;
   }
 
-  const hubId = document.location.pathname.substring(1).split("/")[0];
+  // Connect to reticulum over phoenix channels to get hub info.
+  const hubId = qs.hub_id || document.location.pathname.substring(1).split("/")[0];
   console.log(`Hub ID: ${hubId}`);
-  const res = await fetch(`/api/v1/hubs/${hubId}`);
-  const data = await res.json();
-  const hub = data.hubs[0];
-  const defaultSpaceTopic = hub.topics[0];
-  const gltfBundleUrl = defaultSpaceTopic.assets.find(a => a.asset_type === "gltf_bundle").src;
-  uiRoot.setState({ janusRoomId: defaultSpaceTopic.janus_room_id });
-  initialEnvironmentEl.setAttribute("gltf-bundle", `src: ${gltfBundleUrl}`);
+
+  const socketProtocol = document.location.protocol === "https:" ? "wss:" : "ws:";
+  const socketPort = qs.phx_port || document.location.port;
+  const socketHost = qs.phx_host || document.location.hostname;
+  const socketUrl = `${socketProtocol}//${socketHost}${socketPort ? `:${socketPort}` : ""}/socket`;
+  console.log(`Phoenix Channel URL: ${socketUrl}`);
+
+  const socket = new Socket(socketUrl, { params: { session_id: uuid() } });
+  socket.connect();
+
+  const channel = socket.channel(`hub:${hubId}`, {});
+
+  channel
+    .join()
+    .receive("ok", data => {
+      const hub = data.hubs[0];
+      const defaultSpaceTopic = hub.topics[0];
+      const gltfBundleUrl = defaultSpaceTopic.assets.find(a => a.asset_type === "gltf_bundle").src;
+      remountUI({ janusRoomId: defaultSpaceTopic.janus_room_id });
+      initialEnvironmentEl.setAttribute("gltf-bundle", `src: ${gltfBundleUrl}`);
+      hubChannel.setPhoenixChannel(channel);
+    })
+    .receive("error", res => console.error(res));
 };
 
 document.addEventListener("DOMContentLoaded", onReady);
diff --git a/src/input-mappings.js b/src/input-mappings.js
index 035110dd677efa748c280ae0d6b8f14fde1ae6f4..9296fd71ffa9f31c32308cff00cd7d4bd3096dcf 100644
--- a/src/input-mappings.js
+++ b/src/input-mappings.js
@@ -21,29 +21,37 @@ const config = {
   behaviours: {
     default: {
       "oculus-touch-controls": {
-        joystick: "oculus_touch_joystick_dpad4"
+        joystick: "joystick_dpad4"
       },
       "vive-controls": {
-        trackpad: "vive_trackpad_dpad4"
+        trackpad: "trackpad_dpad4"
+      },
+      "daydream-controls": {
+        trackpad: "trackpad_dpad4"
+      },
+      "gearvr-controls": {
+        trackpad: "trackpad_dpad4"
       }
     }
   },
   mappings: {
     default: {
       "vive-controls": {
-        menudown: ["action_mute", "thumb_down"],
-        menuup: "thumb_up",
         "trackpad.pressedmove": { left: "move" },
         trackpad_dpad4_pressed_west_down: { right: "snap_rotate_left" },
         trackpad_dpad4_pressed_east_down: { right: "snap_rotate_right" },
         trackpad_dpad4_pressed_center_down: { right: "action_teleport_down" },
+        trackpad_dpad4_pressed_north_down: { right: "action_teleport_down" },
+        trackpad_dpad4_pressed_south_down: { right: "action_teleport_down" },
         trackpadup: { right: "action_teleport_up" },
+        menudown: "thumb_down",
+        menuup: "thumb_up",
         gripdown: ["action_grab", "middle_ring_pinky_down", "index_down"],
         gripup: ["action_release", "middle_ring_pinky_up", "index_up"],
         trackpadtouchstart: "thumb_down",
         trackpadtouchend: "thumb_up",
-        triggerdown: "index_down",
-        triggerup: "index_up"
+        triggerdown: ["action_grab", "index_down"],
+        triggerup: ["action_release", "index_up"]
       },
       "oculus-touch-controls": {
         joystick_dpad4_west: {
@@ -52,7 +60,6 @@ const config = {
         joystick_dpad4_east: {
           right: "snap_rotate_right"
         },
-        xbuttondown: "action_mute",
         gripdown: ["action_grab", "middle_ring_pinky_down"],
         gripup: ["action_release", "middle_ring_pinky_up"],
         abuttontouchstart: "thumb_down",
@@ -67,20 +74,26 @@ const config = {
         surfacetouchend: "thumb_up",
         thumbsticktouchstart: "thumb_down",
         thumbsticktouchend: "thumb_up",
-        triggerdown: ["action_teleport_down", "index_down"],
-        triggerup: ["action_teleport_up", "index_up"],
+        triggerdown: "index_down",
+        triggerup: "index_up",
         "axismove.reverseY": { left: "move" },
-        right_dpad_east: "snap_rotate_right",
-        right_dpad_west: "snap_rotate_left",
         abuttondown: "action_teleport_down",
         abuttonup: "action_teleport_up"
       },
       "daydream-controls": {
-        trackpaddown: "action_teleport_down",
+        trackpad_dpad4_pressed_west_down: "snap_rotate_left",
+        trackpad_dpad4_pressed_east_down: "snap_rotate_right",
+        trackpad_dpad4_pressed_center_down: "action_teleport_down",
+        trackpad_dpad4_pressed_north_down: "action_teleport_down",
+        trackpad_dpad4_pressed_south_down: "action_teleport_down",
         trackpadup: "action_teleport_up"
       },
       "gearvr-controls": {
-        trackpaddown: "action_teleport_down",
+        trackpad_dpad4_pressed_west_down: "snap_rotate_left",
+        trackpad_dpad4_pressed_east_down: "snap_rotate_right",
+        trackpad_dpad4_pressed_center_down: "action_teleport_down",
+        trackpad_dpad4_pressed_north_down: "action_teleport_down",
+        trackpad_dpad4_pressed_south_down: "action_teleport_down",
         trackpadup: "action_teleport_up"
       },
       keyboard: {
@@ -100,14 +113,14 @@ const config = {
         s_up: "s_up",
         d_down: "d_down",
         d_up: "d_up",
-        W_down: "w_down",
-        W_up: "w_up",
-        A_down: "a_down",
-        A_up: "a_up",
-        S_down: "s_down",
-        S_up: "s_up",
-        D_down: "d_down",
-        D_up: "d_up"
+        arrowup_down: "w_down",
+        arrowup_up: "w_up",
+        arrowleft_down: "a_down",
+        arrowleft_up: "a_up",
+        arrowdown_down: "s_down",
+        arrowdown_up: "s_up",
+        arrowright_down: "d_down",
+        arrowright_up: "d_up"
       }
     },
     hud: {
@@ -116,8 +129,8 @@ const config = {
         triggerup: { right: "action_ui_select_up" }
       },
       "oculus-touch-controls": {
-        triggerdown: { right: "action_ui_select_down" },
-        triggerup: { right: "action_ui_select_up" },
+        abuttondown: "action_ui_select_down",
+        abuttonup: "action_ui_select_up",
         gripdown: "middle_ring_pinky_down",
         gripup: "middle_ring_pinky_up",
         abuttontouchstart: "thumb_down",
diff --git a/src/network-schemas.js b/src/network-schemas.js
index 54cc0b63ba40efed17baf43c0d3554b1ea2fa4bc..822951bedb782de1ba401ae20c00232f47b61412 100644
--- a/src/network-schemas.js
+++ b/src/network-schemas.js
@@ -3,9 +3,22 @@ function registerNetworkSchemas() {
     template: "#remote-avatar-template",
     components: [
       "position",
-      "rotation",
+      {
+        component: "rotation",
+        lerp: false
+      },
       "scale",
       "player-info",
+      {
+        selector: ".RootScene",
+        component: "hand-pose__left",
+        property: "pose"
+      },
+      {
+        selector: ".RootScene",
+        component: "hand-pose__right",
+        property: "pose"
+      },
       {
         selector: ".camera",
         component: "position"
diff --git a/src/react-components/avatar-selector.js b/src/react-components/avatar-selector.js
index d88340f04532b89d07512d38bb3f292c78e4e844..528d5b81558e37f12aea6a0182c4cc08d8971782 100644
--- a/src/react-components/avatar-selector.js
+++ b/src/react-components/avatar-selector.js
@@ -20,8 +20,8 @@ class AvatarSelector extends Component {
     const numAvatars = this.props.avatars.length;
     return ((currAvatarIndex + direction) % numAvatars + numAvatars) % numAvatars;
   };
-  nextAvatarIndex = () => this.getAvatarIndex(1);
-  previousAvatarIndex = () => this.getAvatarIndex(-1);
+  nextAvatarIndex = () => this.getAvatarIndex(-1);
+  previousAvatarIndex = () => this.getAvatarIndex(1);
 
   emitChangeToNext = () => {
     const nextAvatarId = this.props.avatars[this.nextAvatarIndex()].id;
@@ -38,7 +38,17 @@ class AvatarSelector extends Component {
       // HACK - a-animation ought to restart the animation when the `to` attribute changes, but it doesn't
       // so we need to force it here.
       const currRot = this.animation.parentNode.getAttribute("rotation");
-      this.animation.setAttribute("from", `${currRot.x} ${currRot.y} ${currRot.z}`);
+      const currY = currRot.y;
+      const toRot = String.split(this.animation.attributes.to.value, " ");
+      const toY = toRot[1];
+      const step = 360.0 / this.props.avatars.length;
+      const brokenlyBigRotation = Math.abs(toY - currY) > 3 * step;
+      let fromY = currY;
+      if (brokenlyBigRotation) {
+        // Rotation in Y wrapped around 360. Adjust the "from" to prevent a dramatic rotation
+        fromY = currY < toY ? currY + 360 : currY - 360;
+      }
+      this.animation.setAttribute("from", `${currRot.x} ${fromY} ${currRot.z}`);
       this.animation.stop();
       this.animation.handleMixinUpdate();
       this.animation.start();
@@ -83,7 +93,7 @@ class AvatarSelector extends Component {
               attribute="rotation"
               dur="1000"
               easing="ease-out"
-              to={`0 ${360 * this.getAvatarIndex() / this.props.avatars.length + 180} 0`}
+              to={`0 ${(360 * this.getAvatarIndex() / this.props.avatars.length + 180) % 360} 0`}
             />
             {avatarEntities}
           </a-entity>
diff --git a/src/react-components/entry-buttons.js b/src/react-components/entry-buttons.js
index 8ed8b171d6e7ec08d82d59af2c5314d9353a7910..92d0ef5ef8ccd4992d9913bfb30144fff018c0b9 100644
--- a/src/react-components/entry-buttons.js
+++ b/src/react-components/entry-buttons.js
@@ -12,7 +12,7 @@ import DaydreamEntyImg from "../assets/images/daydream_entry.svg";
 const mobiledetect = new MobileDetect(navigator.userAgent);
 
 const EntryButton = props => (
-  <div className="entry-button" onClick={props.onClick}>
+  <button className="entry-button" onClick={props.onClick}>
     <img src={props.iconSrc} className="entry-button__icon" />
     <div className="entry-button__label">
       <div className="entry-button__label__contents">
@@ -25,7 +25,7 @@ const EntryButton = props => (
         {props.subtitle && <div className="entry-button__subtitle">{props.subtitle}</div>}
       </div>
     </div>
-  </div>
+  </button>
 );
 
 EntryButton.propTypes = {
diff --git a/src/react-components/profile-entry-panel.js b/src/react-components/profile-entry-panel.js
index 523016e25bdf1e0f127bf5a889165b1ede806322..2732f3ca9bd173cc3390bd1af05316b8f6614747 100644
--- a/src/react-components/profile-entry-panel.js
+++ b/src/react-components/profile-entry-panel.js
@@ -14,10 +14,8 @@ class ProfileEntryPanel extends Component {
 
   constructor(props) {
     super(props);
-    this.state = {
-      display_name: this.props.store.state.profile.display_name,
-      avatar_id: this.props.store.state.profile.avatar_id
-    };
+    const { display_name, avatar_id } = this.props.store.state.profile;
+    this.state = { display_name, avatar_id };
     this.props.store.addEventListener("statechanged", this.storeUpdated);
   }
 
@@ -28,10 +26,15 @@ class ProfileEntryPanel extends Component {
 
   saveStateAndFinish = e => {
     e.preventDefault();
+    const has_agreed_to_terms = this.props.store.state.profile.has_agreed_to_terms || this.state.has_agreed_to_terms;
+    if (!has_agreed_to_terms) return;
+    const { has_changed_name, display_name } = this.props.store.state.profile;
+    const hasChangedName = has_changed_name || this.state.display_name !== display_name;
     this.props.store.update({
       profile: {
-        display_name: this.state.display_name,
-        avatar_id: this.state.avatar_id
+        has_agreed_to_terms: true,
+        has_changed_name: hasChangedName,
+        ...this.state
       }
     });
     this.props.finished();
@@ -74,20 +77,47 @@ class ProfileEntryPanel extends Component {
             <div className="profile-entry__subtitle">
               <FormattedMessage id="profile.header" />
             </div>
-            <input
-              className="profile-entry__form-field-text"
-              value={this.state.display_name}
-              onChange={e => this.setState({ display_name: e.target.value })}
-              required
-              pattern={SCHEMA.definitions.profile.properties.display_name.pattern}
-              title={formatMessage({ id: "profile.display_name.validation_warning" })}
-              ref={inp => (this.nameInput = inp)}
-            />
+            <label>
+              <span className="profile-entry__display-name-label">
+                <FormattedMessage id="profile.display_name.label" />
+              </span>
+              <input
+                className="profile-entry__form-field-text"
+                value={this.state.display_name}
+                onChange={e => this.setState({ display_name: e.target.value })}
+                required
+                pattern={SCHEMA.definitions.profile.properties.display_name.pattern}
+                title={formatMessage({ id: "profile.display_name.validation_warning" })}
+                ref={inp => (this.nameInput = inp)}
+              />
+            </label>
             <iframe
               className="profile-entry__avatar-selector"
               src={`/${this.props.htmlPrefix}avatar-selector.html#avatar_id=${this.state.avatar_id}`}
               ref={ifr => (this.avatarSelector = ifr)}
             />
+            {!this.props.store.state.profile.has_agreed_to_terms && (
+              <label className="profile-entry__terms">
+                <input
+                  className="profile-entry__terms__checkbox"
+                  type="checkbox"
+                  required
+                  value={this.state.has_agreed_to_terms}
+                  onChange={e => this.setState({ has_agreed_to_terms: e.target.checked })}
+                />
+                <span className="profile-entry__terms__text">
+                  <FormattedMessage id="profile.terms.prefix" />{" "}
+                  <a className="profile-entry__terms__link" target="_blank" href="/privacy">
+                    <FormattedMessage id="profile.terms.privacy" />
+                  </a>{" "}
+                  <FormattedMessage id="profile.terms.conjunction" />{" "}
+                  <a className="profile-entry__terms__link" target="_blank" href="/terms">
+                    <FormattedMessage id="profile.terms.tou" />
+                  </a>
+                  <FormattedMessage id="profile.terms.suffix" />
+                </span>
+              </label>
+            )}
             <input className="profile-entry__form-submit" type="submit" value={formatMessage({ id: "profile.save" })} />
           </div>
         </form>
diff --git a/src/react-components/profile-info-header.js b/src/react-components/profile-info-header.js
index 43ec49291007089d54e1d1a52c7a586e766e5fb4..ca7a3b891c3c99cc3aca753244ba4289abe525b4 100644
--- a/src/react-components/profile-info-header.js
+++ b/src/react-components/profile-info-header.js
@@ -4,7 +4,7 @@ import PropTypes from "prop-types";
 export const ProfileInfoHeader = props => (
   <div className="profile-info-header">
     <img src="../assets/images/account.svg" onClick={props.onClick} className="profile-info-header__icon" />
-    <div className="profile-info-header__profile_display_name" onClick={props.onClick}>
+    <div className="profile-info-header__profile_display_name" onClick={props.onClick} title={props.name}>
       {props.name}
     </div>
     <div className="profile-info-header__app_name">
diff --git a/src/react-components/ui-root.js b/src/react-components/ui-root.js
index 282f4b4572156fb4e757616a1e8c02e19df34727..3e22208dacc6219b59cc9276562c71d43f129f03 100644
--- a/src/react-components/ui-root.js
+++ b/src/react-components/ui-root.js
@@ -60,11 +60,14 @@ class UIRoot extends Component {
     enableScreenSharing: PropTypes.bool,
     store: PropTypes.object,
     scene: PropTypes.object,
-    htmlPrefix: PropTypes.string
+    htmlPrefix: PropTypes.string,
+    showProfileEntry: PropTypes.bool,
+    availableVREntryTypes: PropTypes.object,
+    initialEnvironmentLoaded: PropTypes.bool,
+    janusRoomId: PropTypes.number
   };
 
   state = {
-    availableVREntryTypes: null,
     entryStep: ENTRY_STEPS.start,
     enterInVR: false,
 
@@ -87,14 +90,16 @@ class UIRoot extends Component {
     autoExitTimerInterval: null,
     secondsRemainingBeforeAutoExit: Infinity,
 
-    initialEnvironmentLoaded: false,
     exited: false,
 
-    showProfileEntry: false,
-
-    janusRoomId: null
+    showProfileEntry: false
   };
 
+  constructor(props) {
+    super(props);
+    this.state.showProfileEntry = this.props.showProfileEntry;
+  }
+
   componentDidMount() {
     this.setupTestTone();
     this.props.concurrentLoadDetector.addEventListener("concurrentload", this.onConcurrentLoad);
@@ -104,8 +109,10 @@ class UIRoot extends Component {
     this.props.scene.addEventListener("stateremoved", this.onAframeStateChanged);
   }
 
-  componentWillUnmount() {
-    this.props.scene.removeEventListener("loaded", this.onSceneLoaded);
+  componentDidUpdate(prevProps) {
+    if (this.props.availableVREntryTypes && prevProps.availableVREntryTypes !== this.props.availableVREntryTypes) {
+      this.handleForcedVREntryType();
+    }
   }
 
   onSceneLoaded = () => {
@@ -250,7 +257,7 @@ class UIRoot extends Component {
   };
 
   enterGearVR = async () => {
-    if (this.state.availableVREntryTypes.gearvr === VR_DEVICE_AVAILABILITY.yes) {
+    if (this.props.availableVREntryTypes.gearvr === VR_DEVICE_AVAILABILITY.yes) {
       await this.performDirectEntryFlow(true);
     } else {
       this.exit();
@@ -269,7 +276,7 @@ class UIRoot extends Component {
   };
 
   enterDaydream = async () => {
-    if (this.state.availableVREntryTypes.daydream == VR_DEVICE_AVAILABILITY.maybe) {
+    if (this.props.availableVREntryTypes.daydream == VR_DEVICE_AVAILABILITY.maybe) {
       this.exit();
 
       // We are not in mobile chrome, so launch into chrome via an Intent URL
@@ -366,23 +373,26 @@ class UIRoot extends Component {
       const AudioContext = window.AudioContext || window.webkitAudioContext;
       const micLevelAudioContext = new AudioContext();
       const micSource = micLevelAudioContext.createMediaStreamSource(mediaStream);
-      const analyzer = micLevelAudioContext.createAnalyser();
-      const levels = new Uint8Array(analyzer.fftSize);
+      const analyser = micLevelAudioContext.createAnalyser();
+      analyser.fftSize = 32;
+      const levels = new Uint8Array(analyser.frequencyBinCount);
 
-      micSource.connect(analyzer);
+      micSource.connect(analyser);
 
       const micUpdateInterval = setInterval(() => {
-        analyzer.getByteTimeDomainData(levels);
-
+        analyser.getByteTimeDomainData(levels);
         let v = 0;
-
         for (let x = 0; x < levels.length; x++) {
-          v = Math.max(levels[x] - 127, v);
+          v = Math.max(levels[x] - 128, v);
         }
-
         const level = v / 128.0;
-        this.micLevelMovingAverage.push(Date.now(), level);
-        this.setState({ micLevel: this.micLevelMovingAverage.movingAverage() });
+        // Multiplier to increase visual indicator.
+        const multiplier = 6;
+        // We use a moving average to smooth out the visual animation or else it would twitch too fast for
+        // the css renderer to keep up.
+        this.micLevelMovingAverage.push(Date.now(), level * multiplier);
+        const average = this.micLevelMovingAverage.movingAverage();
+        this.setState({ micLevel: average });
       }, 50);
 
       const micDeviceId = this.micDeviceIdForMicLabel(this.micLabelForMediaStream(mediaStream));
@@ -464,7 +474,7 @@ class UIRoot extends Component {
   };
 
   onAudioReadyButton = () => {
-    this.props.enterScene(this.state.mediaStream, this.state.enterInVR, this.state.janusRoomId);
+    this.props.enterScene(this.state.mediaStream, this.state.enterInVR, this.props.janusRoomId);
 
     const mediaStream = this.state.mediaStream;
 
@@ -489,7 +499,7 @@ class UIRoot extends Component {
   };
 
   render() {
-    if (!this.state.initialEnvironmentLoaded || !this.state.availableVREntryTypes || !this.state.janusRoomId) {
+    if (!this.props.initialEnvironmentLoaded || !this.props.availableVREntryTypes || !this.props.janusRoomId) {
       return (
         <IntlProvider locale={lang} messages={messages}>
           <div className="loading-panel">
@@ -530,7 +540,7 @@ class UIRoot extends Component {
       /firefox/i.test(navigator.userAgent) && (
         <label className="entry-panel__screen-sharing">
           <input
-            className="entry-panel__screen-sharing-checkbox"
+            className="entry-panel__screen-sharing__checkbox"
             type="checkbox"
             value={this.state.shareScreen}
             onChange={this.setStateAndRequestScreen}
@@ -543,21 +553,21 @@ class UIRoot extends Component {
       this.state.entryStep === ENTRY_STEPS.start ? (
         <div className="entry-panel">
           <TwoDEntryButton onClick={this.enter2D} />
-          {this.state.availableVREntryTypes.generic !== VR_DEVICE_AVAILABILITY.no && (
+          {this.props.availableVREntryTypes.generic !== VR_DEVICE_AVAILABILITY.no && (
             <GenericEntryButton onClick={this.enterVR} />
           )}
-          {this.state.availableVREntryTypes.gearvr !== VR_DEVICE_AVAILABILITY.no && (
+          {this.props.availableVREntryTypes.gearvr !== VR_DEVICE_AVAILABILITY.no && (
             <GearVREntryButton onClick={this.enterGearVR} />
           )}
-          {this.state.availableVREntryTypes.daydream !== VR_DEVICE_AVAILABILITY.no && (
+          {this.props.availableVREntryTypes.daydream !== VR_DEVICE_AVAILABILITY.no && (
             <DaydreamEntryButton
               onClick={this.enterDaydream}
               subtitle={
-                this.state.availableVREntryTypes.daydream == VR_DEVICE_AVAILABILITY.maybe ? daydreamMaybeSubtitle : ""
+                this.props.availableVREntryTypes.daydream == VR_DEVICE_AVAILABILITY.maybe ? daydreamMaybeSubtitle : ""
               }
             />
           )}
-          {this.state.availableVREntryTypes.cardboard !== VR_DEVICE_AVAILABILITY.no && (
+          {this.props.availableVREntryTypes.cardboard !== VR_DEVICE_AVAILABILITY.no && (
             <div className="entry-panel__secondary" onClick={this.enterVR}>
               <FormattedMessage id="entry.cardboard" />
             </div>
@@ -579,28 +589,22 @@ class UIRoot extends Component {
               id={this.state.entryStep == ENTRY_STEPS.mic_grant ? "audio.grant-subtitle" : "audio.granted-subtitle"}
             />
           </div>
-          <div className="mic-grant-panel__icon">
+          <div className="mic-grant-panel__button-container">
             {this.state.entryStep == ENTRY_STEPS.mic_grant ? (
-              <img
-                onClick={this.onMicGrantButton}
-                src="../assets/images/mic_denied.png"
-                srcSet="../assets/images/mic_denied@2x.png 2x"
-                className="mic-grant-panel__icon"
-              />
+              <button className="mic-grant-panel__button" onClick={this.onMicGrantButton}>
+                <img src="../assets/images/mic_denied.png" srcSet="../assets/images/mic_denied@2x.png 2x" />
+              </button>
             ) : (
-              <img
-                onClick={this.onMicGrantButton}
-                src="../assets/images/mic_granted.png"
-                srcSet="../assets/images/mic_granted@2x.png 2x"
-                className="mic-grant-panel__icon"
-              />
+              <button className="mic-grant-panel__button" onClick={this.onMicGrantButton}>
+                <img src="../assets/images/mic_granted.png" srcSet="../assets/images/mic_granted@2x.png 2x" />
+              </button>
             )}
           </div>
-          <div className="mic-grant-panel__next" onClick={this.onMicGrantButton}>
-            <FormattedMessage
-              id={this.state.entryStep == ENTRY_STEPS.mic_grant ? "audio.grant-next" : "audio.granted-next"}
-            />
-          </div>
+          {this.state.entryStep == ENTRY_STEPS.mic_granted && (
+            <button className="mic-grant-panel__next" onClick={this.onMicGrantButton}>
+              <FormattedMessage id="audio.granted-next" />
+            </button>
+          )}
         </div>
       ) : null;
 
@@ -622,39 +626,49 @@ class UIRoot extends Component {
             )}
           </div>
           <div className="audio-setup-panel__levels">
-            <div className="audio-setup-panel__levels__mic">
+            <div className="audio-setup-panel__levels__icon">
+              <img
+                src="../assets/images/level_background.png"
+                srcSet="../assets/images/level_background@2x.png 2x"
+                className="audio-setup-panel__levels__icon-part"
+              />
+              <img
+                src="../assets/images/level_fill.png"
+                srcSet="../assets/images/level_fill@2x.png 2x"
+                className="audio-setup-panel__levels__icon-part"
+                style={micClip}
+              />
               {this.state.audioTrack ? (
                 <img
                   src="../assets/images/mic_level.png"
                   srcSet="../assets/images/mic_level@2x.png 2x"
-                  className="audio-setup-panel__levels__mic_icon"
+                  className="audio-setup-panel__levels__icon-part"
                 />
               ) : (
                 <img
                   src="../assets/images/mic_denied.png"
                   srcSet="../assets/images/mic_denied@2x.png 2x"
-                  className="audio-setup-panel__levels__mic_icon"
+                  className="audio-setup-panel__levels__icon-part"
                 />
               )}
-              <img
-                src="../assets/images/level_fill.png"
-                srcSet="../assets/images/level_fill@2x.png 2x"
-                className="audio-setup-panel__levels__level"
-                style={micClip}
-              />
             </div>
-            <div className="audio-setup-panel__levels__speaker">
+            <div className="audio-setup-panel__levels__icon">
               <img
-                src="../assets/images/speaker_level.png"
-                srcSet="../assets/images/speaker_level@2x.png 2x"
-                className="audio-setup-panel__levels__speaker_icon"
+                src="../assets/images/level_background.png"
+                srcSet="../assets/images/level_background@2x.png 2x"
+                className="audio-setup-panel__levels__icon-part"
               />
               <img
                 src="../assets/images/level_fill.png"
                 srcSet="../assets/images/level_fill@2x.png 2x"
-                className="audio-setup-panel__levels__level"
+                className="audio-setup-panel__levels__icon-part"
                 style={speakerClip}
               />
+              <img
+                src="../assets/images/speaker_level.png"
+                srcSet="../assets/images/speaker_level@2x.png 2x"
+                className="audio-setup-panel__levels__icon-part"
+              />
             </div>
           </div>
           {this.state.audioTrack && (
@@ -670,9 +684,16 @@ class UIRoot extends Component {
                   </option>
                 ))}
               </select>
-              <div className="audio-setup-panel__device-chooser__mic-icon">
-                <img src="../assets/images/mic_small.png" srcSet="../assets/images/mic_small@2x.png 2x" />
-              </div>
+              <img
+                className="audio-setup-panel__device-chooser__mic-icon"
+                src="../assets/images/mic_small.png"
+                srcSet="../assets/images/mic_small@2x.png 2x"
+              />
+              <img
+                className="audio-setup-panel__device-chooser__dropdown-arrow"
+                src="../assets/images/dropdown_arrow.png"
+                srcSet="../assets/images/dropdown_arrow@2x.png 2x"
+              />
             </div>
           )}
           {this.shouldShowHmdMicWarning() && (
@@ -687,9 +708,9 @@ class UIRoot extends Component {
               </span>
             </div>
           )}
-          <div className="audio-setup-panel__enter-button" onClick={this.onAudioReadyButton}>
+          <button className="audio-setup-panel__enter-button" onClick={this.onAudioReadyButton}>
             <FormattedMessage id="audio.enter-now" />
-          </div>
+          </button>
         </div>
       ) : null;
 
diff --git a/src/storage/store.js b/src/storage/store.js
index 6f480aa76db6f14904b611cecf5b0b6b84b1f41f..4351ebeda99e9f9665fcbbf395858296cd2e56a4 100644
--- a/src/storage/store.js
+++ b/src/storage/store.js
@@ -1,5 +1,6 @@
 import uuid from "uuid/v4";
 import { Validator } from "jsonschema";
+import { merge } from "lodash";
 
 const LOCAL_STORE_KEY = "___mozilla_duck";
 const STORE_STATE_CACHE_KEY = Symbol();
@@ -16,6 +17,8 @@ export const SCHEMA = {
       type: "object",
       additionalProperties: false,
       properties: {
+        has_agreed_to_terms: { type: "boolean" },
+        has_changed_name: { type: "boolean" },
         display_name: { type: "string", pattern: "^[A-Za-z0-9-]{3,32}$" },
         avatar_id: { type: "string" }
       }
@@ -27,7 +30,8 @@ export const SCHEMA = {
   properties: {
     id: { type: "string", pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" },
     profile: { $ref: "#/definitions/profile" },
-    lastUsedMicDeviceId: { type: "string" }
+    lastUsedMicDeviceId: { type: "string" },
+    lastEnteredAt: { type: "string" }
   },
 
   additionalProperties: false
@@ -55,7 +59,7 @@ export default class Store extends EventTarget {
       throw new Error("Store id is immutable.");
     }
 
-    const finalState = { ...this.state, ...newState };
+    const finalState = merge(this.state, newState);
     const isValid = validator.validate(finalState, SCHEMA).valid;
 
     if (!isValid) {
diff --git a/src/systems/app-mode.js b/src/systems/app-mode.js
index da5a4239298512ee51c2267b34882fdc32af0118..49e8d361bd90de4612fe4548fc0ecb4cc027d53d 100644
--- a/src/systems/app-mode.js
+++ b/src/systems/app-mode.js
@@ -1,6 +1,6 @@
 /* global AFRAME, console, setTimeout, clearTimeout */
 
-const AppModes = Object.freeze({ DEFAULT: "default", HUD: "hud" });
+export const AppModes = Object.freeze({ DEFAULT: "default", HUD: "hud" });
 
 /**
  * Simple system for keeping track of a modal app state
@@ -89,82 +89,6 @@ AFRAME.registerComponent("app-mode-input-mappings", {
   }
 });
 
-const TWOPI = Math.PI * 2;
-function deltaAngle(a, b) {
-  const p = Math.abs(b - a) % TWOPI;
-  return p > Math.PI ? TWOPI - p : p;
-}
-
-/**
- * Positions the HUD and toggles app mode based on where the user is looking
- */
-AFRAME.registerComponent("hud-controller", {
-  schema: {
-    head: { type: "selector" },
-    offset: { default: 0.7 }, // distance from hud above head,
-    lookCutoff: { default: 20 }, // angle at which the hud should be "on",
-    animRange: { default: 30 }, // degrees over which to animate the hud into view
-    yawCutoff: { default: 50 } // yaw degrees at wich the hud should reoirent even if the user is looking up
-  },
-  init() {
-    this.isYLocked = false;
-    this.lockedHeadPositionY = 0;
-  },
-
-  pause() {
-    // TODO: this assumes full control over current app mode reguardless of what else might be manipulating it, this is obviously wrong
-    const AppModeSystem = this.el.sceneEl.systems["app-mode"];
-    AppModeSystem.setMode(AppModes.DEFAULT);
-  },
-
-  tick() {
-    const hud = this.el.object3D;
-    const head = this.data.head.object3D;
-    const sceneEl = this.el.sceneEl;
-
-    const { offset, lookCutoff, animRange, yawCutoff } = this.data;
-
-    const pitch = head.rotation.x * THREE.Math.RAD2DEG;
-    const yawDif = deltaAngle(head.rotation.y, hud.rotation.y) * THREE.Math.RAD2DEG;
-
-    // Reorient the hud only if the user is looking away from the hud, for right now this arbitrarily means the hud is 1/3 way animated away
-    // TODO: come up with better huristics for this that maybe account for the user turning away from the hud "too far", also animate the position so that it doesnt just snap.
-    if (yawDif >= yawCutoff || pitch < lookCutoff - animRange / 3) {
-      const lookDir = new THREE.Vector3(0, 0, -1);
-      lookDir.applyQuaternion(head.quaternion);
-      lookDir.add(head.position);
-      hud.position.x = lookDir.x;
-      hud.position.z = lookDir.z;
-      hud.setRotationFromEuler(new THREE.Euler(0, head.rotation.y, 0));
-    }
-
-    // animate the hud into place over animRange degrees as the user aproaches the lookCutoff angle
-    const t = 1 - THREE.Math.clamp(lookCutoff - pitch, 0, animRange) / animRange;
-
-    // Lock the hud in place relative to a known head position so it doesn't bob up and down
-    // with the user's head
-    if (!this.isYLocked && t === 1) {
-      this.lockedHeadPositionY = head.position.y;
-    }
-    const EPSILON = 0.001;
-    this.isYLocked = t > 1 - EPSILON;
-
-    hud.position.y = (this.isYLocked ? this.lockedHeadPositionY : head.position.y) + offset + (1 - t) * offset;
-    hud.rotation.x = (1 - t) * THREE.Math.DEG2RAD * 90;
-
-    // update the app mode when the HUD locks on or off
-    // TODO: this assumes full control over current app mode reguardless of what else might be manipulating it, this is obviously wrong
-    const AppModeSystem = sceneEl.systems["app-mode"];
-    if (pitch > lookCutoff && AppModeSystem.mode !== AppModes.HUD) {
-      AppModeSystem.setMode(AppModes.HUD);
-      sceneEl.renderer.sortObjects = true;
-    } else if (pitch < lookCutoff && AppModeSystem.mode === AppModes.HUD) {
-      AppModeSystem.setMode(AppModes.DEFAULT);
-      sceneEl.renderer.sortObjects = false;
-    }
-  }
-});
-
 /**
  * Toggle visibility of an entity based on if the user is in vr mode or not
  */
diff --git a/src/utils/hub-channel.js b/src/utils/hub-channel.js
new file mode 100644
index 0000000000000000000000000000000000000000..13e51b21b1e1f3702432a7a04c6804949c332b88
--- /dev/null
+++ b/src/utils/hub-channel.js
@@ -0,0 +1,75 @@
+import moment from "moment-timezone";
+
+export default class HubChannel {
+  constructor(store) {
+    this.store = store;
+  }
+
+  setPhoenixChannel = channel => {
+    this.channel = channel;
+  };
+
+  sendEntryEvent = async () => {
+    if (!this.channel) {
+      console.warn("No phoenix channel initialized before room entry.");
+      return;
+    }
+
+    let entryDisplayType = "Screen";
+
+    if (navigator.getVRDisplays) {
+      const vrDisplay = (await navigator.getVRDisplays()).find(d => d.isPresenting);
+
+      if (vrDisplay) {
+        entryDisplayType = vrDisplay.displayName;
+      }
+    }
+
+    // This is fairly hacky, but gets the # of initial occupants
+    let initialOccupantCount = 0;
+
+    if (NAF.connection.adapter && NAF.connection.adapter.publisher) {
+      initialOccupantCount = NAF.connection.adapter.publisher.initialOccupants.length;
+    }
+
+    const entryTimingFlags = this.getEntryTimingFlags();
+
+    const entryEvent = {
+      ...entryTimingFlags,
+      initialOccupantCount,
+      entryDisplayType,
+      userAgent: navigator.userAgent
+    };
+
+    this.channel.push("events:entered", entryEvent);
+  };
+
+  getEntryTimingFlags = () => {
+    const entryTimingFlags = { isNewDaily: true, isNewMonthly: true, isNewDayWindow: true, isNewMonthWindow: true };
+
+    if (!this.store.state.lastEnteredAt) {
+      return entryTimingFlags;
+    }
+
+    const lastEntered = moment(this.store.state.lastEnteredAt);
+    const lastEnteredPst = moment(lastEntered).tz("America/Los_Angeles");
+    const nowPst = moment().tz("America/Los_Angeles");
+    const dayWindowAgo = moment().subtract(1, "day");
+    const monthWindowAgo = moment().subtract(1, "month");
+
+    entryTimingFlags.isNewDaily =
+      lastEnteredPst.dayOfYear() !== nowPst.dayOfYear() || lastEnteredPst.year() !== nowPst.year();
+    entryTimingFlags.isNewMonthly =
+      lastEnteredPst.month() !== nowPst.month() || lastEnteredPst.year() !== nowPst.year();
+    entryTimingFlags.isNewDayWindow = lastEntered.isBefore(dayWindowAgo);
+    entryTimingFlags.isNewMonthWindow = lastEntered.isBefore(monthWindowAgo);
+
+    return entryTimingFlags;
+  };
+
+  disconnect = () => {
+    if (this.channel) {
+      this.channel.socket.disconnect();
+    }
+  };
+}
diff --git a/src/utils/identity.js b/src/utils/identity.js
index def830cbc4df4d9d1a71b0c10d7b54c5a5610b4a..db78b027e3e851aa254532f264438e362062576c 100644
--- a/src/utils/identity.js
+++ b/src/utils/identity.js
@@ -1,178 +1,108 @@
 import { avatars } from "../assets/avatars/avatars.js";
 
 const names = [
-  "albattani",
-  "allen",
-  "almeida",
-  "agnesi",
-  "archimedes",
-  "ardinghelli",
-  "aryabhata",
-  "austin",
-  "babbage",
-  "banach",
-  "bardeen",
-  "bartik",
-  "bassi",
-  "beaver",
-  "bell",
-  "benz",
-  "bhabha",
-  "bhaskara",
-  "blackwell",
-  "bohr",
-  "booth",
-  "borg",
-  "bose",
-  "boyd",
-  "brahmagupta",
-  "brattain",
-  "brown",
-  "carson",
-  "chandrasekhar",
-  "shannon",
-  "clarke",
-  "colden",
-  "cori",
-  "cray",
-  "curran",
-  "curie",
-  "darwin",
-  "davinci",
-  "dijkstra",
-  "dubinsky",
-  "easley",
-  "edison",
-  "einstein",
-  "elion",
-  "engelbart",
-  "euclid",
-  "euler",
-  "fermat",
-  "fermi",
-  "feynman",
-  "franklin",
-  "galileo",
-  "gates",
-  "goldberg",
-  "goldstine",
-  "goldwasser",
-  "golick",
-  "goodall",
-  "haibt",
-  "hamilton",
-  "hawking",
-  "heisenberg",
-  "hermann",
-  "heyrovsky",
-  "hodgkin",
-  "hoover",
-  "hopper",
-  "hugle",
-  "hypatia",
-  "jackson",
-  "jang",
-  "jennings",
-  "jepsen",
-  "johnson",
-  "joliot",
-  "jones",
-  "kalam",
-  "kare",
-  "keller",
-  "kepler",
-  "khorana",
-  "kilby",
-  "kirch",
-  "knuth",
-  "kowalevski",
-  "lalande",
-  "lamarr",
-  "lamport",
-  "leakey",
-  "leavitt",
-  "lewin",
-  "lichterman",
-  "liskov",
-  "lovelace",
-  "lumiere",
-  "mahavira",
-  "mayer",
-  "mccarthy",
-  "mcclintock",
-  "mclean",
-  "mcnulty",
-  "meitner",
-  "meninsky",
-  "mestorf",
-  "minsky",
-  "mirzakhani",
-  "morse",
-  "murdock",
-  "neumann",
-  "newton",
-  "nightingale",
-  "nobel",
-  "noether",
-  "northcutt",
-  "noyce",
-  "panini",
-  "pare",
-  "pasteur",
-  "payne",
-  "perlman",
-  "pike",
-  "poincare",
-  "poitras",
-  "ptolemy",
-  "raman",
-  "ramanujan",
-  "ride",
-  "montalcini",
-  "ritchie",
-  "roentgen",
-  "rosalind",
-  "saha",
-  "sammet",
-  "shaw",
-  "shirley",
-  "shockley",
-  "sinoussi",
-  "snyder",
-  "spence",
-  "stallman",
-  "stonebraker",
-  "swanson",
-  "swartz",
-  "swirles",
-  "tesla",
-  "thompson",
-  "torvalds",
-  "turing",
-  "varahamihira",
-  "visvesvaraya",
-  "volhard",
-  "wescoff",
-  "wiles",
-  "williams",
-  "wilson",
-  "wing",
-  "wozniak",
-  "wright",
-  "yalow",
-  "yonath"
+  "Baers-Pochard",
+  "Baikal-Teal",
+  "Barrows-Goldeneye",
+  "Blue-Billed",
+  "Blue-Duck",
+  "Blue-Winged",
+  "Brown-Teal",
+  "Bufflehead",
+  "Canvasback",
+  "Cape-Shoveler",
+  "Chestnut-Teal",
+  "Chiloe-Wigeon",
+  "Cinnamon-Teal",
+  "Comb-Duck",
+  "Common-Eider",
+  "Common-Goldeneye",
+  "Common-Merganser",
+  "Common-Pochard",
+  "Common-Scoter",
+  "Common-Shelduck",
+  "Cotton-Pygmy",
+  "Crested-Duck",
+  "Crested-Shelduck",
+  "Eatons-Pintail",
+  "Falcated",
+  "Ferruginous",
+  "Freckled-Duck",
+  "Gadwall",
+  "Garganey",
+  "Greater-Scaup",
+  "Green-Pygmy",
+  "Grey-Teal",
+  "Hardhead",
+  "Harlequin",
+  "Hartlaubs",
+  "Hooded-Merganser",
+  "Hottentot-Teal",
+  "Kelp-Goose",
+  "King-Eider",
+  "Lake-Duck",
+  "Laysan-Duck",
+  "Lesser-Scaup",
+  "Long-Tailed",
+  "Maccoa-Duck",
+  "Mallard",
+  "Mandarin",
+  "Marbled-Teal",
+  "Mellers-Duck",
+  "Merganser",
+  "Northern-Pintail",
+  "Orinoco-Goose",
+  "Paradise-Shelduck",
+  "Plumed-Whistler",
+  "Puna-Teal",
+  "Pygmy-Goose",
+  "Radjah-Shelduck",
+  "Red-Billed",
+  "Red-Crested",
+  "Red-Shoveler",
+  "Ring-Necked",
+  "Ringed-Teal",
+  "Rosy-Billed",
+  "Ruddy-Shelduck",
+  "Salvadoris-Teal",
+  "Scaly-Sided",
+  "Shelduck",
+  "Shoveler",
+  "Silver-Teal",
+  "Smew",
+  "Spectacled-Eider",
+  "Spot-Billed",
+  "Spotted-Whistler",
+  "Steamerduck",
+  "Stellers-Eider",
+  "Sunda-Teal",
+  "Surf-Scoter",
+  "Tufted-Duck",
+  "Velvet-Scoter",
+  "Wandering-Whistler",
+  "Whistling-duck",
+  "White-Backed",
+  "White-Cheeked",
+  "White-Winged",
+  "Wigeon",
+  "Wood-Duck",
+  "Yellow-Billed"
 ];
 
 function selectRandom(arr) {
   return arr[Math.floor(Math.random() * arr.length)];
 }
 
+export function generateRandomName() {
+  return `${selectRandom(names)}-${Math.floor(10000 + Math.random() * 10000)}`;
+}
+
 export const avatarIds = avatars.map(av => av.id);
 
 export function generateDefaultProfile() {
-  const name = selectRandom(names);
   return {
-    display_name: name.replace(/^./, name[0].toUpperCase()),
+    has_agreed_to_terms: false,
+    has_changed_name: false,
     avatar_id: selectRandom(avatarIds)
   };
 }
diff --git a/yarn.lock b/yarn.lock
index 97096cfc4ee0f9850dfba4d5e359d96c115c78d3..4391edbc296f19f7af084c959e516b67caed2123 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -118,7 +118,7 @@ accepts@1.3.3:
     mime-types "~2.1.11"
     negotiator "0.6.1"
 
-accepts@~1.3.4:
+accepts@~1.3.4, accepts@~1.3.5:
   version "1.3.5"
   resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.5.tgz#eb777df6011723a3b14e8a72c0805c8e86746bd2"
   dependencies:
@@ -170,19 +170,13 @@ aframe-extras@^4.0.0:
   version "0.1.2"
   resolved "https://github.com/johnshaughnessy/aframe-input-mapping-component#4c7e493ad6c4a25eef27d32551c94d8b78541191"
 
-aframe-lerp-component@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/aframe-lerp-component/-/aframe-lerp-component-1.1.0.tgz#dc8c12c53698770c30f25eef8873e34a4e79c765"
-  dependencies:
-    almost-equal "^1.1.0"
-
 "aframe-physics-extras@https://github.com/infinitelee/aframe-physics-extras#fix/physics-collider-crash":
   version "0.1.2"
   resolved "https://github.com/infinitelee/aframe-physics-extras#49b2d5d3c0caac905783aee51d9e89dbdf7199b8"
 
-"aframe-physics-system@https://github.com/donmccurdy/aframe-physics-system":
-  version "3.0.1"
-  resolved "https://github.com/donmccurdy/aframe-physics-system#08a98a4c3d77c4c38a1fa27067aa0d894447902e"
+"aframe-physics-system@https://github.com/infinitelee/aframe-physics-system#feature/shape-component":
+  version "3.0.2"
+  resolved "https://github.com/infinitelee/aframe-physics-system#be3e43f1af5b995952e1b3d27dce216ab2e79f05"
   dependencies:
     browserify "^14.3.0"
     budo "^10.0.3"
@@ -238,10 +232,6 @@ ajv@^6.0.1, ajv@^6.1.0:
     fast-json-stable-stringify "^2.0.0"
     json-schema-traverse "^0.3.0"
 
-almost-equal@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/almost-equal/-/almost-equal-1.1.0.tgz#f851c631138757994276aa2efbe8dfa3066cccdd"
-
 alphanum-sort@^1.0.1, alphanum-sort@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3"
@@ -474,6 +464,10 @@ async@0.2.x:
   version "0.2.10"
   resolved "https://registry.yarnpkg.com/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1"
 
+async@1.4.2:
+  version "1.4.2"
+  resolved "https://registry.yarnpkg.com/async/-/async-1.4.2.tgz#6c9edcb11ced4f0dd2f2d40db0d49a109c088aab"
+
 async@^1.5.0, async@^1.5.2:
   version "1.5.2"
   resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a"
@@ -1351,7 +1345,7 @@ boom@2.x.x:
   dependencies:
     hoek "2.x.x"
 
-brace-expansion@^1.1.7:
+brace-expansion@^1.0.0, brace-expansion@^1.1.7:
   version "1.1.11"
   resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
   dependencies:
@@ -1904,6 +1898,13 @@ cli-width@^2.0.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639"
 
+cli@0.6.x:
+  version "0.6.6"
+  resolved "https://registry.yarnpkg.com/cli/-/cli-0.6.6.tgz#02ad44a380abf27adac5e6f0cdd7b043d74c53e3"
+  dependencies:
+    exit "0.1.2"
+    glob "~ 3.2.1"
+
 cliui@^3.2.0:
   version "3.2.0"
   resolved "https://registry.yarnpkg.com/cliui/-/cliui-3.2.0.tgz#120601537a916d29940f934da3b48d585a39213d"
@@ -2010,14 +2011,18 @@ colormin@^1.0.5:
     css-color-names "0.0.4"
     has "^1.0.1"
 
-colors@*, colors@^1.1.2, colors@~1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/colors/-/colors-1.1.2.tgz#168a4701756b6a7f51a12ce0c97bfa28c084ed63"
+colors@*:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/colors/-/colors-1.2.1.tgz#f4a3d302976aaf042356ba1ade3b1a2c62d9d794"
 
 colors@1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b"
 
+colors@^1.1.2, colors@~1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/colors/-/colors-1.1.2.tgz#168a4701756b6a7f51a12ce0c97bfa28c084ed63"
+
 combine-source-map@~0.7.1:
   version "0.7.2"
   resolved "https://registry.yarnpkg.com/combine-source-map/-/combine-source-map-0.7.2.tgz#0870312856b307a87cc4ac486f3a9a62aeccc09e"
@@ -2046,6 +2051,10 @@ commander@2.14.x, commander@^2.9.0, commander@~2.14.1:
   version "2.14.1"
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.14.1.tgz#2235123e37af8ca3c65df45b026dbd357b01b9aa"
 
+commander@2.6.0:
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-2.6.0.tgz#9df7e52fb2a0cb0fb89058ee80c3104225f37e1d"
+
 commondir@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
@@ -2112,7 +2121,7 @@ connect-pushstate@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/connect-pushstate/-/connect-pushstate-1.1.0.tgz#bcab224271c439604a0fb0f614c0a5f563e88e24"
 
-console-browserify@^1.1.0:
+console-browserify@1.1.x, console-browserify@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.1.0.tgz#f0241c45730a9fc6323b206dbf38edc741d0bb10"
   dependencies:
@@ -2300,6 +2309,12 @@ cssesc@^0.1.0:
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-0.1.0.tgz#c814903e45623371a0477b40109aaafbeeaddbb4"
 
+csslint@0.10.0:
+  version "0.10.0"
+  resolved "https://registry.yarnpkg.com/csslint/-/csslint-0.10.0.tgz#3a6a04e7565c8e9d19beb49767c7ec96e8365805"
+  dependencies:
+    parserlib "~0.2.2"
+
 cssnano@^3.10.0:
   version "3.10.0"
   resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-3.10.0.tgz#4f38f6cea2b9b17fa01490f23f1dc68ea65c1c38"
@@ -2634,13 +2649,19 @@ domhandler@2.1:
   dependencies:
     domelementtype "1"
 
+domhandler@2.3:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.3.0.tgz#2de59a0822d5027fabff6f032c2b25a2a8abe738"
+  dependencies:
+    domelementtype "1"
+
 domutils@1.1:
   version "1.1.6"
   resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.1.6.tgz#bddc3de099b9a2efacc51c623f28f416ecc57485"
   dependencies:
     domelementtype "1"
 
-domutils@1.5.1:
+domutils@1.5, domutils@1.5.1:
   version "1.5.1"
   resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf"
   dependencies:
@@ -2792,6 +2813,10 @@ enhanced-resolve@^4.0.0:
     memory-fs "^0.4.0"
     tapable "^1.0.0"
 
+entities@1.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/entities/-/entities-1.0.0.tgz#b2987aa3821347fcde642b24fdfc9e4fb712bf26"
+
 entities@~1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.1.tgz#6e5c2d0a5621b5dadaecef80b90edfb5cd7772f0"
@@ -3021,6 +3046,10 @@ exit-hook@^1.0.0:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8"
 
+exit@0.1.2, exit@0.1.x:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c"
+
 expand-brackets@^0.1.4:
   version "0.1.5"
   resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-0.1.5.tgz#df07284e342a807cd733ac5af72411e581d1177b"
@@ -3051,7 +3080,42 @@ expand-tilde@^2.0.0, expand-tilde@^2.0.2:
   dependencies:
     homedir-polyfill "^1.0.1"
 
-express@^4.10.7, express@^4.16.2:
+express@^4.10.7:
+  version "4.16.3"
+  resolved "https://registry.yarnpkg.com/express/-/express-4.16.3.tgz#6af8a502350db3246ecc4becf6b5a34d22f7ed53"
+  dependencies:
+    accepts "~1.3.5"
+    array-flatten "1.1.1"
+    body-parser "1.18.2"
+    content-disposition "0.5.2"
+    content-type "~1.0.4"
+    cookie "0.3.1"
+    cookie-signature "1.0.6"
+    debug "2.6.9"
+    depd "~1.1.2"
+    encodeurl "~1.0.2"
+    escape-html "~1.0.3"
+    etag "~1.8.1"
+    finalhandler "1.1.1"
+    fresh "0.5.2"
+    merge-descriptors "1.0.1"
+    methods "~1.1.2"
+    on-finished "~2.3.0"
+    parseurl "~1.3.2"
+    path-to-regexp "0.1.7"
+    proxy-addr "~2.0.3"
+    qs "6.5.1"
+    range-parser "~1.2.0"
+    safe-buffer "5.1.1"
+    send "0.16.2"
+    serve-static "1.13.2"
+    setprototypeof "1.1.0"
+    statuses "~1.4.0"
+    type-is "~1.6.16"
+    utils-merge "1.0.1"
+    vary "~1.1.2"
+
+express@^4.16.2:
   version "4.16.2"
   resolved "https://registry.yarnpkg.com/express/-/express-4.16.2.tgz#e35c6dfe2d64b7dca0a5cd4f21781be3299e076c"
   dependencies:
@@ -3257,6 +3321,18 @@ finalhandler@1.1.0:
     statuses "~1.3.1"
     unpipe "~1.0.0"
 
+finalhandler@1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.1.tgz#eebf4ed840079c83f4249038c9d703008301b105"
+  dependencies:
+    debug "2.6.9"
+    encodeurl "~1.0.2"
+    escape-html "~1.0.3"
+    on-finished "~2.3.0"
+    parseurl "~1.3.2"
+    statuses "~1.4.0"
+    unpipe "~1.0.0"
+
 find-cache-dir@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-1.0.0.tgz#9288e3e9e3cc3748717d39eade17cf71fc30ee6f"
@@ -3532,6 +3608,16 @@ glob-parent@^3.1.0:
     is-glob "^3.1.0"
     path-dirname "^1.0.0"
 
+glob@5.0.15:
+  version "5.0.15"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-5.0.15.tgz#1bc936b9e02f4a603fcc222ecf7633d30b8b93b1"
+  dependencies:
+    inflight "^1.0.4"
+    inherits "2"
+    minimatch "2 || 3"
+    once "^1.3.0"
+    path-is-absolute "^1.0.0"
+
 glob@^6.0.1, glob@^6.0.4:
   version "6.0.4"
   resolved "https://registry.yarnpkg.com/glob/-/glob-6.0.4.tgz#0f08860f6a155127b2fadd4f9ce24b1aab6e4d22"
@@ -3553,6 +3639,13 @@ glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.1.0, glob@^7.1.2, glob@~7.1.1:
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
+"glob@~ 3.2.1":
+  version "3.2.11"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-3.2.11.tgz#4a973f635b9190f715d10987d5c00fd2815ebe3d"
+  dependencies:
+    inherits "2"
+    minimatch "0.3"
+
 global-modules@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-1.0.0.tgz#6d770f0eb523ac78164d72b5e71a8877265cc3ea"
@@ -3896,6 +3989,30 @@ htmlescape@^1.1.0:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/htmlescape/-/htmlescape-1.1.1.tgz#3a03edc2214bca3b66424a3e7959349509cb0351"
 
+htmlhint@^0.9.13:
+  version "0.9.13"
+  resolved "https://registry.yarnpkg.com/htmlhint/-/htmlhint-0.9.13.tgz#08163cb1e6aa505048ebb0b41063a7ca07dc6c88"
+  dependencies:
+    async "1.4.2"
+    colors "1.0.3"
+    commander "2.6.0"
+    csslint "0.10.0"
+    glob "5.0.15"
+    jshint "2.8.0"
+    parse-glob "3.0.4"
+    strip-json-comments "1.0.4"
+    xml "1.0.0"
+
+htmlparser2@3.8.x:
+  version "3.8.3"
+  resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.8.3.tgz#996c28b191516a8be86501a7d79757e5c70c1068"
+  dependencies:
+    domelementtype "1"
+    domhandler "2.3"
+    domutils "1.5"
+    entities "1.0"
+    readable-stream "1.1"
+
 htmlparser2@~3.3.0:
   version "3.3.0"
   resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.3.0.tgz#cc70d05a59f6542e43f0e685c982e14c924a9efe"
@@ -3909,7 +4026,7 @@ http-deceiver@^1.2.7:
   version "1.2.7"
   resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87"
 
-http-errors@1.6.2, http-errors@~1.6.2:
+http-errors@1.6.2:
   version "1.6.2"
   resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.2.tgz#0a002cc85707192a7e7946ceedc11155f60ec736"
   dependencies:
@@ -3918,6 +4035,15 @@ http-errors@1.6.2, http-errors@~1.6.2:
     setprototypeof "1.0.3"
     statuses ">= 1.3.1 < 2"
 
+http-errors@~1.6.2:
+  version "1.6.3"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d"
+  dependencies:
+    depd "~1.1.2"
+    inherits "2.0.3"
+    setprototypeof "1.1.0"
+    statuses ">= 1.4.0 < 2"
+
 http-parser-js@>=0.4.0:
   version "0.4.10"
   resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.4.10.tgz#92c9c1374c35085f75db359ec56cc257cbb93fa4"
@@ -4523,6 +4649,19 @@ jsesc@~0.5.0:
   version "0.5.0"
   resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d"
 
+jshint@2.8.0:
+  version "2.8.0"
+  resolved "https://registry.yarnpkg.com/jshint/-/jshint-2.8.0.tgz#1d09a3bd913c4cadfa81bf18d582bd85bffe0d44"
+  dependencies:
+    cli "0.6.x"
+    console-browserify "1.1.x"
+    exit "0.1.x"
+    htmlparser2 "3.8.x"
+    lodash "3.7.x"
+    minimatch "2.0.x"
+    shelljs "0.3.x"
+    strip-json-comments "1.0.x"
+
 json-schema-traverse@^0.3.0:
   version "0.3.1"
   resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz#349a6d44c53a51de89b40805c5d5e59b417d3340"
@@ -4789,6 +4928,10 @@ lodash.uniq@^4.5.0:
   version "4.5.0"
   resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
 
+lodash@3.7.x:
+  version "3.7.0"
+  resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.7.0.tgz#3678bd8ab995057c07ade836ed2ef087da811d45"
+
 lodash@^4.0.0, lodash@^4.11.1, lodash@^4.13.1, lodash@^4.14.0, lodash@^4.17.2, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.0, lodash@^4.3.0, lodash@~4.17.4:
   version "4.17.5"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.5.tgz#99a92d65c0272debe8c96b6057bc8fbfa3bed511"
@@ -4841,6 +4984,10 @@ lowercase-keys@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.0.tgz#4e3366b39e7f5457e35f1324bdf6f88d0bfc7306"
 
+lru-cache@2:
+  version "2.7.3"
+  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-2.7.3.tgz#6d4524e8b955f95d4f5b58851ce21dd72fb4e952"
+
 lru-cache@^4.0.1, lru-cache@^4.1.1:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.1.tgz#622e32e82488b49279114a4f9ecf45e7cd6bba55"
@@ -5051,12 +5198,25 @@ minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a"
 
+minimatch@0.3:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-0.3.0.tgz#275d8edaac4f1bb3326472089e7949c8394699dd"
+  dependencies:
+    lru-cache "2"
+    sigmund "~1.0.0"
+
 "minimatch@2 || 3", minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.4, minimatch@~3.0.2:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
   dependencies:
     brace-expansion "^1.1.7"
 
+minimatch@2.0.x:
+  version "2.0.10"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-2.0.10.tgz#8d087c39c6b38c001b97fca7ce6d0e1e80afbac7"
+  dependencies:
+    brace-expansion "^1.0.0"
+
 minimist@0.0.8:
   version "0.0.8"
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
@@ -5148,6 +5308,16 @@ module-deps@^6.0.0:
     through2 "^2.0.0"
     xtend "^4.0.0"
 
+moment-timezone@^0.5.14:
+  version "0.5.14"
+  resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.14.tgz#4eb38ff9538b80108ba467a458f3ed4268ccfcb1"
+  dependencies:
+    moment ">= 2.9.0"
+
+"moment@>= 2.9.0", moment@^2.22.0:
+  version "2.22.0"
+  resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.0.tgz#7921ade01017dd45186e7fee5f424f0b8663a730"
+
 move-concurrently@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"
@@ -5250,10 +5420,9 @@ neo-async@^2.5.0:
   resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.5.0.tgz#76b1c823130cca26acfbaccc8fbaf0a2fa33b18f"
 
 "networked-aframe@https://github.com/mozillareality/networked-aframe#mr-social-client/master":
-  version "0.5.1"
-  resolved "https://github.com/mozillareality/networked-aframe#5d2f50ddf65140f0ae671b2b53c1c667de18dca5"
+  version "0.6.1"
+  resolved "https://github.com/mozillareality/networked-aframe#69be0e7e5f66070526c8240cb795b9e88da971a9"
   dependencies:
-    aframe-lerp-component "^1.1.0"
     easyrtc "1.1.0"
     express "^4.10.7"
     serve-static "^1.8.0"
@@ -5717,7 +5886,7 @@ parse-asn1@^5.0.0:
     evp_bytestokey "^1.0.0"
     pbkdf2 "^3.0.3"
 
-parse-glob@^3.0.4:
+parse-glob@3.0.4, parse-glob@^3.0.4:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/parse-glob/-/parse-glob-3.0.4.tgz#b2c376cfb11f35513badd173ef0bb6e3a388391c"
   dependencies:
@@ -5752,6 +5921,10 @@ parseqs@0.0.5:
   dependencies:
     better-assert "~1.0.0"
 
+parserlib@~0.2.2:
+  version "0.2.5"
+  resolved "https://registry.yarnpkg.com/parserlib/-/parserlib-0.2.5.tgz#85907dd8605aa06abb3dd295d50bb2b8fa4dd117"
+
 parseuri@0.0.5:
   version "0.0.5"
   resolved "https://registry.yarnpkg.com/parseuri/-/parseuri-0.0.5.tgz#80204a50d4dbb779bfdc6ebe2778d90e4bce320a"
@@ -5851,6 +6024,10 @@ performance-now@^0.2.0:
   version "0.2.0"
   resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-0.2.0.tgz#33ef30c5c77d4ea21c5a53869d91b56d8f2555e5"
 
+phoenix@^1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/phoenix/-/phoenix-1.3.0.tgz#1df2c27f986ee295e37c9983ec28ebac1d7f4a3e"
+
 pify@^2.0.0, pify@^2.3.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
@@ -6222,7 +6399,7 @@ prop-types@^15.5.4, prop-types@^15.6.0:
     loose-envify "^1.3.1"
     object-assign "^4.1.1"
 
-proxy-addr@~2.0.2:
+proxy-addr@~2.0.2, proxy-addr@~2.0.3:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.3.tgz#355f262505a621646b3130a728eb647e22055341"
   dependencies:
@@ -6477,6 +6654,15 @@ readable-stream@1.0, "readable-stream@>=1.0.33-1 <1.1.0-0":
     isarray "0.0.1"
     string_decoder "~0.10.x"
 
+readable-stream@1.1:
+  version "1.1.13"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.13.tgz#f6eef764f514c89e2b9e23146a75ba106756d23e"
+  dependencies:
+    core-util-is "~1.0.0"
+    inherits "~2.0.1"
+    isarray "0.0.1"
+    string_decoder "~0.10.x"
+
 readable-stream@~2.0.0:
   version "2.0.6"
   resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.0.6.tgz#8f90341e68a53ccc928788dacfcd11b36eb9b78e"
@@ -6981,7 +7167,7 @@ serve-static@1.13.1:
     parseurl "~1.3.2"
     send "0.16.1"
 
-serve-static@^1.10.0, serve-static@^1.8.0:
+serve-static@1.13.2, serve-static@^1.10.0, serve-static@^1.8.0:
   version "1.13.2"
   resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.13.2.tgz#095e8472fd5b46237db50ce486a43f4b86c6cec1"
   dependencies:
@@ -7075,6 +7261,10 @@ shell-quote@^1.4.2, shell-quote@^1.6.1:
     array-reduce "~0.0.0"
     jsonify "~0.0.0"
 
+shelljs@0.3.x:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.3.0.tgz#3596e6307a781544f591f37da618360f31db57b1"
+
 shelljs@^0.7.0:
   version "0.7.8"
   resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.7.8.tgz#decbcf874b0d1e5fb72e14b164a9683048e9acb3"
@@ -7083,6 +7273,18 @@ shelljs@^0.7.0:
     interpret "^1.0.0"
     rechoir "^0.6.2"
 
+shelljs@^0.8.1:
+  version "0.8.1"
+  resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.1.tgz#729e038c413a2254c4078b95ed46e0397154a9f1"
+  dependencies:
+    glob "^7.0.0"
+    interpret "^1.0.0"
+    rechoir "^0.6.2"
+
+sigmund@~1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/sigmund/-/sigmund-1.0.1.tgz#3ff21f198cad2175f9f3b781853fd94d0d19b590"
+
 signal-exit@^3.0.0, signal-exit@^3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"
@@ -7341,7 +7543,11 @@ static-extend@^0.1.1:
     define-property "^0.2.5"
     object-copy "^0.1.0"
 
-"statuses@>= 1.3.1 < 2", statuses@~1.3.1:
+"statuses@>= 1.3.1 < 2", "statuses@>= 1.4.0 < 2":
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
+
+statuses@~1.3.1:
   version "1.3.1"
   resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.3.1.tgz#faf51b9eb74aaef3b3acf4ad5f61abf24cb7b93e"
 
@@ -7487,6 +7693,10 @@ strip-indent@^1.0.1:
   dependencies:
     get-stdin "^4.0.1"
 
+strip-json-comments@1.0.4, strip-json-comments@1.0.x:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-1.0.4.tgz#1e15fbcac97d3ee99bf2d73b4c656b082bbafb91"
+
 strip-json-comments@~2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
@@ -7769,7 +7979,7 @@ type-check@~0.3.2:
   dependencies:
     prelude-ls "~1.1.2"
 
-type-is@~1.6.15:
+type-is@~1.6.15, type-is@~1.6.16:
   version "1.6.16"
   resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.16.tgz#f89ce341541c672b25ee7ae3c73dee3b2be50194"
   dependencies:
@@ -8353,6 +8563,10 @@ xml-char-classes@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/xml-char-classes/-/xml-char-classes-1.0.0.tgz#64657848a20ffc5df583a42ad8a277b4512bbc4d"
 
+xml@1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/xml/-/xml-1.0.0.tgz#de3ee912477be2f250b60f612f34a8c4da616efe"
+
 xmlhttprequest-ssl@1.5.3:
   version "1.5.3"
   resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.3.tgz#185a888c04eca46c3e4070d99f7b49de3528992d"