diff --git a/package.json b/package.json
index 1a2a4de3fcf9897845e94e022b156bcc199ce6a7..033fc274b7e8b599ffd39c09c4cb1e399e4548e1 100644
--- a/package.json
+++ b/package.json
@@ -19,7 +19,7 @@
     "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-teleport-controls": "https://github.com/netpro2k/aframe-teleport-controls#feature/teleport-origin",
+    "aframe-teleport-controls": "https://github.com/netpro2k/aframe-teleport-controls#feature/pauseable",
     "aframe-xr": "github:brianpeiris/aframe-xr#3162aed",
     "classnames": "^2.2.5",
     "detect-browser": "^2.1.0",
diff --git a/src/assets/hud/avatar.jpg b/src/assets/hud/avatar.jpg
new file mode 100755
index 0000000000000000000000000000000000000000..af8093c601f6dcbc74b431ff7f7aca414cf4dc01
Binary files /dev/null and b/src/assets/hud/avatar.jpg differ
diff --git a/src/assets/hud/muted.png b/src/assets/hud/muted.png
index 7a15a9ea9e9125e04739214c0fad7c0226d5eca2..b557fd0195e53a1dc9b4873db65b69afb0b7d1b1 100644
Binary files a/src/assets/hud/muted.png and b/src/assets/hud/muted.png differ
diff --git a/src/assets/hud/unmuted.png b/src/assets/hud/unmuted.png
new file mode 100755
index 0000000000000000000000000000000000000000..71f1af21bdd72aaa22ebe740e84a481aa1dc8b3f
Binary files /dev/null and b/src/assets/hud/unmuted.png differ
diff --git a/src/assets/stylesheets/ui-root.scss b/src/assets/stylesheets/ui-root.scss
index 9d252f04d8ccb94b203a5cd2ef46ec2caf062d2b..74f4ad4a37f344162f1ee029a51fd5e0525e2728 100644
--- a/src/assets/stylesheets/ui-root.scss
+++ b/src/assets/stylesheets/ui-root.scss
@@ -28,18 +28,21 @@
 
 .ui-dialog-box {
   grid-column: 3;
-  grid-row : 3;
+  grid-row: 3;
   position: relative;
 }
 
 .ui-dialog-box-contents {
   background-color: $darker-transparent;
   border-radius: 8px;
-  pointer-events: auto;
   width: 100%;
   height: 100%;
 }
 
+.ui-interactive {
+  pointer-events: auto;
+}
+
 .ui-dialog-box--backgrounded {
 }
 
@@ -48,4 +51,3 @@
   opacity: 0.7;
   pointer-events: none;
 }
-
diff --git a/src/components/2d-mute-state-indicator.css b/src/components/2d-mute-state-indicator.css
deleted file mode 100644
index 9713edb2ee92e8b0b8e0eafb5a7e8ac41719dcac..0000000000000000000000000000000000000000
--- a/src/components/2d-mute-state-indicator.css
+++ /dev/null
@@ -1,12 +0,0 @@
-:local(.indicator) {
-    position: absolute;
-    top: 10px;
-    left: calc(50vw - 16px);
-    width: 32px;
-    height: 32px;
-    background-size: 100%;
-}
-
-:local(.indicator.muted) {
-    background-image: url(../assets/hud/muted.png);
-}
diff --git a/src/components/2d-mute-state-indicator.js b/src/components/2d-mute-state-indicator.js
deleted file mode 100644
index 95df937d8ded1e13e5d125bfb3295b118649a2be..0000000000000000000000000000000000000000
--- a/src/components/2d-mute-state-indicator.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import styles from "./2d-mute-state-indicator.css";
-/**
- * Shows a 2d incicator on screen reflecting mute state
- * @TODO this probably shouldnt be an aframe component but baring any other 2d UI handling it feels cleaner here than jsut free-flaoting
- */
-AFRAME.registerComponent("2d-mute-state-indicator", {
-  schema: {},
-
-  init() {
-    this.onStateToggled = this.onStateToggled.bind(this);
-
-    this.muteIcon = document.createElement("div");
-    this.muteIcon.classList.add(styles.indicator);
-    document.body.appendChild(this.muteIcon);
-
-    this.updateMuteState();
-  },
-
-  play() {
-    this.el.sceneEl.addEventListener("stateadded", this.onStateToggled);
-    this.el.sceneEl.addEventListener("stateremoved", this.onStateToggled);
-  },
-
-  pause() {
-    this.el.sceneEl.removeEventListener("stateadded", this.onStateToggled);
-    this.el.sceneEl.removeEventListener("stateremoved", this.onStateToggled);
-  },
-
-  onStateToggled(e) {
-    if (!e.detail.state === "muted") return;
-    this.updateMuteState();
-  },
-
-  updateMuteState() {
-    const muted = this.el.sceneEl.is("muted");
-    this.muteIcon.classList.toggle(styles.muted, muted);
-  }
-});
diff --git a/src/components/character-controller.js b/src/components/character-controller.js
index 8aaa2240a24bf169bd72f6e957f54031f436e7eb..20553d36ce9c06ca84806fd5b6adc7350ddf2aaa 100644
--- a/src/components/character-controller.js
+++ b/src/components/character-controller.js
@@ -41,6 +41,14 @@ AFRAME.registerComponent("character-controller", {
     eventSrc.removeEventListener("rotateY", this.setAngularVelocity);
     eventSrc.removeEventListener("snap_rotate_left", this.snapRotateLeft);
     eventSrc.removeEventListener("snap_rotate_right", this.snapRotateRight);
+    this.reset();
+  },
+
+  reset() {
+    this.accelerationInput.set(0, 0, 0);
+    this.velocity.set(0, 0, 0);
+    this.angularVelocity = 0;
+    this.pendingSnapRotationMatrix.identity();
   },
 
   setAccelerationInput: function(event) {
@@ -82,6 +90,9 @@ AFRAME.registerComponent("character-controller", {
       const distance = this.data.groundAcc * deltaSeconds;
       const rotationDelta = this.data.rotationSpeed * this.angularVelocity * deltaSeconds;
 
+      // Other aframe components like teleport-controls set position/rotation/scale, not the matrix, so we need to make sure to compose them back into the matrix
+      root.updateMatrix();
+
       pivotPos.copy(pivot.position);
       pivotPos.applyMatrix4(root.matrix);
       trans.setPosition(pivotPos);
diff --git a/src/components/in-world-hud.js b/src/components/in-world-hud.js
new file mode 100644
index 0000000000000000000000000000000000000000..e69cfd7374eaa1ca1972b3cb00784d837dc987b8
--- /dev/null
+++ b/src/components/in-world-hud.js
@@ -0,0 +1,121 @@
+AFRAME.registerComponent("in-world-hud", {
+  schema: {
+    haptic: { type: "selector" },
+    raycaster: { type: "selector" }
+  },
+  init() {
+    this.bg = this.el.querySelector(".bg");
+    this.mic = this.el.querySelector(".mic");
+    this.nametag = this.el.querySelector(".username");
+    this.nametag.object3DMap.text.material.depthTest = false;
+    this.data.raycaster.components.line.material.depthTest = false;
+
+    const muted = this.el.sceneEl.is("muted");
+    this.mic.setAttribute("src", muted ? "#muted" : "#unmuted");
+
+    const scene = this.el.sceneEl;
+    this.onUsernameChanged = this.onUsernameChanged.bind(this);
+    scene.addEventListener("username-changed", this.onUsernameChanged);
+
+    this.showCorrectMuteState = () => {
+      const muted = this.el.sceneEl.is("muted");
+      this.mic.setAttribute("src", muted ? "#muted" : "#unmuted");
+    };
+
+    this.onStateChange = evt => {
+      if (evt.detail !== "muted") return;
+      this.showCorrectMuteState();
+    };
+
+    this.onMicHover = () => {
+      this.hoveredOnMic = true;
+      this.data.haptic.emit("haptic_pulse", { intensity: "low" });
+      this.mic.setAttribute("material", "color", "#1DD");
+    };
+
+    this.onMicHoverExit = () => {
+      this.hoveredOnMic = false;
+      this.mic.setAttribute("material", "color", "#FFF");
+      this.showCorrectMuteState();
+    };
+
+    this.onMicDown = () => {
+      this.data.haptic.emit("haptic_pulse", { intensity: "medium" });
+      this.el.sceneEl.removeEventListener("micAudio", this.onAudioFrequencyChange);
+      this.mic.setAttribute("material", "color", this.el.sceneEl.is("muted") ? "#0FA" : "#F33");
+      this.el.emit("action_mute");
+      window.setTimeout(() => {
+        this.mic.setAttribute("material", "color", "#FFF");
+        this.el.sceneEl.addEventListener("micAudio", this.onAudioFrequencyChange);
+      }, 150);
+    };
+
+    this.onClick = () => {
+      if (this.hoveredOnMic) {
+        this.onMicDown();
+      }
+    };
+
+    this.onAudioFrequencyChange = e => {
+      if (this.hoveredOnMic) return;
+      const red = 1.0 - e.detail.volume / 10.0;
+      this.mic.object3DMap.mesh.material.color = { r: red, g: 9, b: 9 };
+    };
+
+    this.el.sceneEl.addEventListener("mediaStream", evt => {
+      this.ms = evt.detail.ms;
+      const ctx = THREE.AudioContext.getContext();
+      const source = ctx.createMediaStreamSource(this.ms);
+      this.analyser = ctx.createAnalyser();
+      this.levels = new Uint8Array(this.analyser.frequencyBinCount);
+      source.connect(this.analyser);
+    });
+  },
+
+  play() {
+    this.mic.addEventListener("raycaster-intersected", this.onMicHover);
+    this.mic.addEventListener("raycaster-intersected-cleared", this.onMicHoverExit);
+
+    this.el.sceneEl.addEventListener("stateadded", this.onStateChange);
+    this.el.sceneEl.addEventListener("stateremoved", this.onStateChange);
+
+    this.el.addEventListener("click", this.onClick);
+
+    this.el.sceneEl.addEventListener("micAudio", this.onAudioFrequencyChange);
+  },
+
+  pause() {
+    this.nametag.removeEventListener("raycaster-intersected", this.onNametagHovered);
+    this.nametag.removeEventListener("raycaster-intersected-cleared", this.onNametagUnhovered);
+
+    this.el.sceneEl.removeEventListener("stateadded", this.onStateChange);
+    this.el.sceneEl.removeEventListener("stateremoved", this.onStateChange);
+
+    this.el.removeEventListener("click", this.onClick);
+
+    this.el.sceneEl.removeEventListener("micAudio", this.onAudioFrequencyChange);
+  },
+
+  tick: function(t, dt) {
+    if (!this.analyser) return;
+
+    this.analyser.getByteFrequencyData(this.levels);
+
+    let sum = 0;
+    for (let i = 0; i < this.levels.length; i++) {
+      sum += this.levels[i];
+    }
+    this.volume = sum / this.levels.length;
+    this.el.emit("micAudio", {
+      volume: this.volume,
+      levels: this.levels
+    });
+  },
+
+  onUsernameChanged(evt) {
+    const { username } = evt.detail;
+    const width = evt.detail.username.length == 0 ? 1 : 40 / username.length;
+    this.nametag.setAttribute("text", "width", Math.min(width, 6));
+    this.nametag.setAttribute("text", "value", username);
+  }
+});
diff --git a/src/components/wasd-to-analog2d.js b/src/components/wasd-to-analog2d.js
index 1b83f8f63a2ef969300ef1c91a0814f04a1f0ed8..a424cb893ab10c29575667f7c68539dd65e90689 100644
--- a/src/components/wasd-to-analog2d.js
+++ b/src/components/wasd-to-analog2d.js
@@ -17,17 +17,8 @@ AFRAME.registerComponent("wasd-to-analog2d", {
   },
 
   play: function() {
-    const eventNames = [
-      "w_down",
-      "w_up",
-      "a_down",
-      "a_up",
-      "s_down",
-      "s_up",
-      "d_down",
-      "d_up"
-    ];
-    for (var name of eventNames) {
+    const eventNames = ["w_down", "w_up", "a_down", "a_up", "s_down", "s_up", "d_down", "d_up"];
+    for (const name of eventNames) {
       this.el.sceneEl.addEventListener(name, this.onWasd);
     }
     // I listen to events that this component generates instead of emitting "move"
@@ -42,10 +33,8 @@ AFRAME.registerComponent("wasd-to-analog2d", {
 
   pause: function() {
     this.el.sceneEl.removeEventListener("wasd", this.onWasd);
-    this.el.sceneEl.removeEventListener(
-      this.data.analog2dOutputAction,
-      this.move
-    );
+    this.el.sceneEl.removeEventListener(this.data.analog2dOutputAction, this.move);
+    this.keys = {};
   },
 
   onWasd: function(event) {
@@ -58,18 +47,13 @@ AFRAME.registerComponent("wasd-to-analog2d", {
   tick: function(t, dt) {
     this.target = [0, 0];
 
-    for (var key in this.keys) {
+    for (const key in this.keys) {
       if (this.keys[key] && this.vectors[key]) {
-        this.target = [
-          this.target[0] + this.vectors[key][0],
-          this.target[1] + this.vectors[key][1]
-        ];
+        this.target = [this.target[0] + this.vectors[key][0], this.target[1] + this.vectors[key][1]];
       }
     }
 
-    const targetMagnitude = Math.sqrt(
-      this.target[0] * this.target[0] + this.target[1] * this.target[1]
-    );
+    const targetMagnitude = Math.sqrt(this.target[0] * this.target[0] + this.target[1] * this.target[1]);
     if (targetMagnitude !== 0) {
       this.target[0] = this.target[0] / targetMagnitude;
       this.target[1] = this.target[1] / targetMagnitude;
diff --git a/src/hub.html b/src/hub.html
index ad23c9c83950d0afd78e25bf27f9cdf38bdc10c0..5aeaf6c0f5afd9ebbba17c7bf26fb8cee15f479b 100644
--- a/src/hub.html
+++ b/src/hub.html
@@ -19,10 +19,15 @@
     <a-scene
         physics
         mute-mic="eventSrc: a-scene; toggleEvents: action_mute"
-        2d-mute-state-indicator
-        light="defaultLightsEnabled: false">
+
+        app-mode-input-mappings="modes: default, hud; actionSets: default, hud;"
+        >
 
         <a-assets>
+            <img id="unmuted"  src="./assets/hud/unmuted.png" >
+            <img id="muted"  src="./assets/hud/muted.png" >
+            <img id="avatar"  src="./assets/hud/avatar.jpg" >
+
             <a-progressive-asset
                 id="bot-skinned-mesh"
                 response-type="arraybuffer"
@@ -133,6 +138,8 @@
                 radius=0.02
                 static-body="shape: sphere;"
                 mixin="super-hands"
+                segments-height="9"
+                segments-width="9"
             ></a-sphere>  
         </a-entity>
 
@@ -144,8 +151,25 @@
             wasd-to-analog2d
             character-controller="pivot: #player-camera"
             ik-root
+            app-mode-toggle-playing__character-controller="mode: hud; invert: true;"
+            app-mode-toggle-playing__wasd-to-analog2d="mode: hud; invert: true;"
             player-info
         >
+
+            <a-entity
+                id="player-hud"
+                hud-controller="head: #player-camera;"
+                vr-mode-toggle-visibility
+                vr-mode-toggle-playing__hud-controller
+            >
+                <a-entity in-world-hud="haptic:#player-right-controller;raycaster:#player-right-controller;" rotation="-39 0 0">
+                    <a-box geometry="height:0.13;width:0.6;depth:0.001" material="depthTest:false; color:#000000;opacity:0.35" class="hud bg"></a-box>
+                    <a-image src="#unmuted" scale="-0.1 0.1 0.1" position="-0.2 0 0.001" class="hud mic" material="alphaTest:0.1;depthTest:false;"></a-image>
+                    <a-text scale="0.3 0.3 0.3" position="0 0 0.001" class="hud username" text="width:6;alphaTest:0.1;align:center;"></a-text>
+                    <a-image src="#avatar" scale="0.1 0.1 0.1" position="0.2 0 0.001" class="hud avatar" material="depthTest:false;"></a-image>
+                </a-entity>
+            </a-entity>
+
             <a-entity
                 id="player-camera"
                 class="camera"
@@ -161,9 +185,12 @@
                 hand-controls2="left"
                 tracked-controls
                 teleport-controls="cameraRig: #player-rig; teleportOrigin: #player-camera; button: action_teleport_"
+                app-mode-toggle-playing__teleport-controls="mode: hud; invert: true;"
                 haptic-feedback
             ></a-entity>
 
+
+
             <a-entity
                 id="player-right-controller"
                 class="right-controller"
@@ -171,6 +198,13 @@
                 tracked-controls
                 teleport-controls="cameraRig: #player-rig; teleportOrigin: #player-camera; button: action_teleport_"
                 haptic-feedback
+                raycaster="objects:.hud; showLine: true;"
+                cursor="fuse: false; downEvents: action_ui_select_down; upEvents: action_ui_select_up;"
+
+                app-mode-toggle-playing__teleport-controls="mode: hud; invert: true;"
+                app-mode-toggle-playing__raycaster="mode: hud;"
+                app-mode-toggle-playing__cursor="mode: hud;"
+                app-mode-toggle-attribute__line="mode: hud; property: visible;"
             ></a-entity>
 
             <a-gltf-entity class="model" inflate="true">
@@ -271,7 +305,7 @@
         ></a-plane> 
     </a-scene>
 
-    <div id="ui-root" class="ui"></div>
+    <div id="ui-root"></div>
 </body>
 
 </html>
diff --git a/src/hub.js b/src/hub.js
index c81d90fb0e937a220f38179a1b20f8690ce65a6b..b0899d5dda41acbe5aa2a88582f91556dba4648b 100644
--- a/src/hub.js
+++ b/src/hub.js
@@ -23,7 +23,7 @@ import "./components/wasd-to-analog2d"; //Might be a behaviour or activator in t
 import "./components/mute-mic";
 import "./components/audio-feedback";
 import "./components/bone-mute-state-indicator";
-import "./components/2d-mute-state-indicator";
+import "./components/in-world-hud";
 import "./components/virtual-gamepad-controls";
 import "./components/ik-controller";
 import "./components/hand-controls2";
@@ -48,6 +48,9 @@ import React from "react";
 import UIRoot from "./react-components/ui-root";
 
 import "./systems/personal-space-bubble";
+import "./systems/app-mode";
+
+import "./elements/a-gltf-entity";
 
 import "./gltf-component-mappings";
 
@@ -133,11 +136,13 @@ async function exitScene() {
 }
 
 function updatePlayerInfoFromStore() {
+  const displayName = store.state.profile.display_name;
   const playerRig = document.querySelector("#player-rig");
   playerRig.setAttribute("player-info", {
-    displayName: store.state.profile.display_name,
+    displayName,
     avatar: qs.avatar || "#bot-skinned-mesh"
   });
+  document.querySelector("a-scene").emit("username-changed", { username: displayName });
 }
 
 async function enterScene(mediaStream, enterInVR, janusRoomId) {
diff --git a/src/input-mappings.js b/src/input-mappings.js
index 6449f41c43cbda7a44fd3ec3e5edf51b2376f63e..a5338250079c08c94d79edb84457ff4e3b4e7b65 100644
--- a/src/input-mappings.js
+++ b/src/input-mappings.js
@@ -10,6 +10,10 @@ const inGameActions = {
     action_teleport_down: { label: "Teleport Aim" },
     action_teleport_up: { label: "Teleport" },
     action_share_screen: { label: "Share Screen" }
+  },
+  hud: {
+    action_ui_select_down: { label: "Select UI item" },
+    action_ui_select_up: { label: "Select UI item" }
   }
 };
 
@@ -79,6 +83,7 @@ const config = {
         q_press: "snap_rotate_left",
         e_press: "snap_rotate_right",
         v_press: "action_share_screen",
+        b_press: "action_select_hud_item",
 
         // We can't create a keyboard behaviour with AFIM yet,
         // so these will get captured by wasd-to-analog2d
@@ -99,6 +104,40 @@ const config = {
         D_down: "d_down",
         D_up: "d_up"
       }
+    },
+    hud: {
+      "vive-controls": {
+        triggerdown: { right: "action_ui_select_down" },
+        triggerup: { right: "action_ui_select_up" }
+      },
+      "oculus-touch-controls": {
+        triggerdown: { right: "action_ui_select_down" },
+        triggerup: { right: "action_ui_select_up" },
+        gripdown: "middle_ring_pinky_down",
+        gripup: "middle_ring_pinky_up",
+        abuttontouchstart: "thumb_down",
+        abuttontouchend: "thumb_up",
+        bbuttontouchstart: "thumb_down",
+        bbuttontouchend: "thumb_up",
+        xbuttontouchstart: "thumb_down",
+        xbuttontouchend: "thumb_up",
+        ybuttontouchstart: "thumb_down",
+        ybuttontouchend: "thumb_up",
+        surfacetouchstart: "thumb_down",
+        surfacetouchend: "thumb_up",
+        thumbsticktouchstart: "thumb_down",
+        thumbsticktouchend: "thumb_up",
+        triggertouchstart: "index_down",
+        triggertouchend: "index_up"
+      },
+      "daydream-controls": {
+        trackpaddown: { right: "action_ui_select_down" },
+        trackpadup: { right: "action_ui_select_up" }
+      },
+      "gearvr-controls": {
+        trackpaddown: { right: "action_ui_select_down" },
+        trackpadup: { right: "action_ui_select_up" }
+      }
     }
   }
 };
diff --git a/src/react-components/2d-hud.css b/src/react-components/2d-hud.css
new file mode 100644
index 0000000000000000000000000000000000000000..556431ac6ca824e0bbcf4cf4d5644e05be4dc309
--- /dev/null
+++ b/src/react-components/2d-hud.css
@@ -0,0 +1,65 @@
+:local(.container) {
+  position: absolute;
+  top: 10px;
+  display: flex;
+  justify-content: center;
+  height: 60px;
+  width: 100%;
+}
+
+:local(.bg) {
+  position: absolute;
+  display: flex;
+  justify-content: space-around;
+  align-items: center;
+  padding: 10px;
+  background-color: rgba(0, 0, 0, 0.35);
+}
+
+:local(.nametag) {
+  display: flex;
+  justify-content: center;
+  font-size: 32px;
+  font-family: sans-serif;
+  color: white;
+  margin: 0 10px 0 0;
+}
+
+:local(.avatar) {
+  display: flex;
+  width: 48px;
+  height: 48px;
+  background-size: 100%;
+  background-image: url(../assets/hud/avatar.jpg);
+}
+
+:local(.mic) {
+  display: flex;
+  width: 48px;
+  height: 48px;
+  mask: url(../assets/hud/unmuted.png);
+  mask-size: 48px;
+  background-color: white;
+  cursor: pointer;
+}
+
+:local(.mic:hover) {
+  background-color: cyan;
+}
+
+:local(.mic.muted:hover) {
+  background-color: cyan;
+}
+
+:local(.mic:active) {
+  background-color: red;
+}
+
+:local(.mic.muted) {
+  mask: url(../assets/hud/muted.png);
+  mask-size: 48px;
+}
+
+:local(.mic.muted:active) {
+  background-color: green;
+}
diff --git a/src/react-components/2d-hud.js b/src/react-components/2d-hud.js
new file mode 100644
index 0000000000000000000000000000000000000000..fda3ae06ad0ce63b7b773d5a69f4141b52389c9f
--- /dev/null
+++ b/src/react-components/2d-hud.js
@@ -0,0 +1,23 @@
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import cx from "classnames";
+
+import styles from "./2d-hud.css";
+
+const TwoDHUD = ({ name, muted, onToggleMute }) => (
+  <div className={styles.container}>
+    <div className={cx("ui-interactive", styles.bg)}>
+      <div className={cx(styles.mic, { [styles.muted]: muted })} onClick={onToggleMute} />
+      <div className={styles.nametag}>{name}</div>
+      <div className={styles.avatar} />
+    </div>
+  </div>
+);
+
+TwoDHUD.propTypes = {
+  name: PropTypes.string,
+  muted: PropTypes.bool,
+  onToggleMute: PropTypes.func
+};
+
+export default TwoDHUD;
diff --git a/src/react-components/ui-root.js b/src/react-components/ui-root.js
index e951f62db575cf96a9c59a92dc3254e8ba0722ea..f2d6f9102d23ccb25375abf75bf1733bb2b28375 100644
--- a/src/react-components/ui-root.js
+++ b/src/react-components/ui-root.js
@@ -1,25 +1,27 @@
-import React, { Component } from 'react';
-import PropTypes from 'prop-types';
-import classNames from 'classnames';
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import classNames from "classnames";
 import { VR_DEVICE_AVAILABILITY } from "../utils/vr-caps-detect";
 import queryString from "query-string";
 import { SCHEMA } from "../storage/store";
-import MobileDetect from 'mobile-detect';
-import { IntlProvider, FormattedMessage, addLocaleData } from 'react-intl';
-import en from 'react-intl/locale-data/en';
-import MovingAverage from 'moving-average';
+import MobileDetect from "mobile-detect";
+import { IntlProvider, FormattedMessage, addLocaleData } from "react-intl";
+import en from "react-intl/locale-data/en";
+import MovingAverage from "moving-average";
 
-import AutoExitWarning from './auto-exit-warning';
-import { TwoDEntryButton, GenericEntryButton, GearVREntryButton, DaydreamEntryButton } from './entry-buttons.js';
-import { ProfileInfoHeader } from './profile-info-header.js';
-import ProfileEntryPanel from './profile-entry-panel';
+import AutoExitWarning from "./auto-exit-warning";
+import { TwoDEntryButton, GenericEntryButton, GearVREntryButton, DaydreamEntryButton } from "./entry-buttons.js";
+import { ProfileInfoHeader } from "./profile-info-header.js";
+import ProfileEntryPanel from "./profile-entry-panel";
+import TwoDHUD from "./2d-hud";
 
 const mobiledetect = new MobileDetect(navigator.userAgent);
 
-const lang = ((navigator.languages && navigator.languages[0]) ||
-                   navigator.language || navigator.userLanguage).toLowerCase().split(/[_-]+/)[0];
+const lang = ((navigator.languages && navigator.languages[0]) || navigator.language || navigator.userLanguage)
+  .toLowerCase()
+  .split(/[_-]+/)[0];
 
-import localeData from '../assets/translations.data.json';
+import localeData from "../assets/translations.data.json";
 addLocaleData([...en]);
 
 const messages = localeData[lang] || localeData.en;
@@ -30,7 +32,7 @@ const ENTRY_STEPS = {
   mic_granted: "mic_granted",
   audio_setup: "audio_setup",
   finished: "finished"
-}
+};
 
 const HMD_MIC_REGEXES = [/\Wvive\W/i, /\Wrift\W/i];
 
@@ -64,7 +66,7 @@ class UIRoot extends Component {
     forcedVREntryType: PropTypes.string,
     store: PropTypes.object,
     scene: PropTypes.object
-  }
+  };
 
   state = {
     availableVREntryTypes: null,
@@ -99,8 +101,31 @@ class UIRoot extends Component {
     this.setupTestTone();
     this.props.concurrentLoadDetector.addEventListener("concurrentload", this.onConcurrentLoad);
     this.micLevelMovingAverage = MovingAverage(100);
+    this.props.scene.addEventListener("loaded", this.onSceneLoaded);
+    this.props.scene.addEventListener("stateadded", this.onAframeStateChanged);
+    this.props.scene.addEventListener("stateremoved", this.onAframeStateChanged);
+  }
+
+  componentWillUnmount() {
+    this.props.scene.removeEventListener("loaded", this.onSceneLoaded);
   }
 
+  onSceneLoaded = () => {
+    this.setState({ sceneLoaded: true });
+  };
+
+  // TODO: mute state should probably actually just live in react land
+  onAframeStateChanged = e => {
+    if (e.detail !== "muted") return;
+    this.setState({
+      muted: this.props.scene.is("muted")
+    });
+  };
+
+  toggleMute = () => {
+    this.props.scene.emit("action_mute");
+  };
+
   handleForcedVREntryType = () => {
     if (!this.props.forcedVREntryType) return;
 
@@ -109,7 +134,7 @@ class UIRoot extends Component {
     } else if (this.props.forcedVREntryType === "gearvr") {
       this.enterGearVR();
     }
-  }
+  };
 
   setupTestTone = () => {
     const toneClip = document.querySelector("#test-tone");
@@ -121,27 +146,29 @@ class UIRoot extends Component {
 
       setTimeout(() => {
         this.setState({ tonePlaying: true });
-        setTimeout(() => { this.setState({ tonePlaying: false }); }, toneLength)
+        setTimeout(() => {
+          this.setState({ tonePlaying: false });
+        }, toneLength);
       }, toneDelay);
     };
 
     toneClip.addEventListener("seeked", toneIndicatorLoop);
     toneClip.addEventListener("playing", toneIndicatorLoop);
-  }
+  };
 
   startTestTone = () => {
     const toneClip = document.querySelector("#test-tone");
     toneClip.loop = true;
     toneClip.play();
-  }
+  };
 
   stopTestTone = () => {
-    const toneClip = document.querySelector("#test-tone")
+    const toneClip = document.querySelector("#test-tone");
     toneClip.pause();
     toneClip.currentTime = 0;
 
-    this.setState({ tonePlaying: false })
-  }
+    this.setState({ tonePlaying: false });
+  };
 
   onConcurrentLoad = () => {
     if (this.props.disableAutoExitOnConcurrentLoad) return;
@@ -158,33 +185,37 @@ class UIRoot extends Component {
       this.checkForAutoExit();
     }, 500);
 
-    this.setState({ autoExitTimerStartedAt: new Date(), autoExitTimerInterval })
-  }
+    this.setState({ autoExitTimerStartedAt: new Date(), autoExitTimerInterval });
+  };
 
   checkForAutoExit = () => {
     if (this.state.secondsRemainingBeforeAutoExit !== 0) return;
     this.endAutoExitTimer();
     this.exit();
-  }
+  };
 
   exit = () => {
     this.props.exitScene();
     this.setState({ exited: true });
-  }
+  };
 
   isWaitingForAutoExit = () => {
     return this.state.secondsRemainingBeforeAutoExit <= AUTO_EXIT_TIMER_SECONDS;
-  }
+  };
 
   endAutoExitTimer = () => {
     clearInterval(this.state.autoExitTimerInterval);
-    this.setState({ autoExitTimerStartedAt: null, autoExitTimerInterval: null, secondsRemainingBeforeAutoExit: Infinity });
-  }
+    this.setState({
+      autoExitTimerStartedAt: null,
+      autoExitTimerInterval: null,
+      secondsRemainingBeforeAutoExit: Infinity
+    });
+  };
 
-  performDirectEntryFlow = async (enterInVR) => {
+  performDirectEntryFlow = async enterInVR => {
     this.startTestTone();
 
-    this.setState({ enterInVR })
+    this.setState({ enterInVR });
 
     const hasGrantedMic = await hasGrantedMicPermissions();
 
@@ -195,15 +226,15 @@ class UIRoot extends Component {
       this.stopTestTone();
       this.setState({ entryStep: ENTRY_STEPS.mic_grant });
     }
-  }
+  };
 
   enter2D = async () => {
     await this.performDirectEntryFlow(false);
-  }
+  };
 
   enterVR = async () => {
     await this.performDirectEntryFlow(true);
-  }
+  };
 
   enterGearVR = async () => {
     this.exit();
@@ -212,10 +243,11 @@ class UIRoot extends Component {
     const qs = queryString.parse(document.location.search);
     qs.vr_entry_type = "gearvr"; // Auto-choose 'gearvr' after landing in Oculus Browser
 
-    const ovrwebUrl = `ovrweb://${document.location.protocol || "http:"}//${document.location.host}${document.location.pathname || ""}?${queryString.stringify(qs)}#{document.location.hash || ""}`;
+    const ovrwebUrl = `ovrweb://${document.location.protocol || "http:"}//${document.location.host}${document.location
+      .pathname || ""}?${queryString.stringify(qs)}#{document.location.hash || ""}`;
 
     document.location = ovrwebUrl;
-  }
+  };
 
   enterDaydream = async () => {
     const loc = document.location;
@@ -227,27 +259,32 @@ class UIRoot extends Component {
       const qs = queryString.parse(document.location.search);
       qs.vr_entry_type = "daydream"; // Auto-choose 'daydream' after landing in chrome
 
-      const intentUrl = `intent://${document.location.host}${document.location.pathname || ""}?${queryString.stringify(qs)}#Intent;scheme=${(document.location.protocol || "http:").replace(":", "")};action=android.intent.action.VIEW;package=com.android.chrome;end;`;
+      const intentUrl = `intent://${document.location.host}${document.location.pathname || ""}?${queryString.stringify(
+        qs
+      )}#Intent;scheme=${(document.location.protocol || "http:").replace(
+        ":",
+        ""
+      )};action=android.intent.action.VIEW;package=com.android.chrome;end;`;
       document.location = intentUrl;
     } else {
       await this.performDirectEntryFlow(true);
     }
-  }
+  };
 
   mediaVideoConstraint = () => {
     return this.state.shareScreen ? { mediaSource: "screen", height: 720, frameRate: 30 } : false;
-  }
+  };
 
-  micDeviceChanged = async (ev) => {
+  micDeviceChanged = async ev => {
     const constraints = { audio: { deviceId: { exact: [ev.target.value] } }, video: this.mediaVideoConstraint() };
     await this.setupNewMediaStream(constraints);
-  }
+  };
 
   setMediaStreamToDefault = async () => {
     await this.setupNewMediaStream({ audio: true, video: false });
-  }
+  };
 
-  setupNewMediaStream = async (constraints) => {
+  setupNewMediaStream = async constraints => {
     const AudioContext = window.AudioContext || window.webkitAudioContext;
     const audioContext = new AudioContext();
 
@@ -280,13 +317,13 @@ class UIRoot extends Component {
         v = Math.max(levels[x] - 127, v);
       }
 
-      const level = v / 128.0 ;
+      const level = v / 128.0;
       this.micLevelMovingAverage.push(Date.now(), level);
-      this.setState({ micLevel: this.micLevelMovingAverage.movingAverage() })
+      this.setState({ micLevel: this.micLevelMovingAverage.movingAverage() });
     }, 50);
 
     this.setState({ mediaStream, micUpdateInterval });
-  }
+  };
 
   onMicGrantButton = async () => {
     if (this.state.entryStep == ENTRY_STEPS.mic_grant) {
@@ -296,43 +333,48 @@ class UIRoot extends Component {
       this.startTestTone();
       await this.beginAudioSetup();
     }
-  }
+  };
 
   onProfileFinished = () => {
-    this.setState({ showProfileEntry: false })
-  }
+    this.setState({ showProfileEntry: false });
+  };
 
   beginAudioSetup = async () => {
     await this.fetchMicDevices();
     this.setState({ entryStep: ENTRY_STEPS.audio_setup });
-  }
+  };
 
   fetchMicDevices = async () => {
     const mediaDevices = await navigator.mediaDevices.enumerateDevices();
-    this.setState({ micDevices: mediaDevices.filter(d => d.kind === "audioinput").map(d => ({ deviceId: d.deviceId, label: d.label }))});
-  }
+    this.setState({
+      micDevices: mediaDevices.filter(d => d.kind === "audioinput").map(d => ({ deviceId: d.deviceId, label: d.label }))
+    });
+  };
 
   shouldShowHmdMicWarning = () => {
     if (mobiledetect.mobile()) return false;
     if (!this.state.enterInVR) return false;
     if (!this.hasHmdMicrophone()) return false;
 
-    return !(HMD_MIC_REGEXES.find(r => this.selectedMicLabel().match(r)));
-  }
+    return !HMD_MIC_REGEXES.find(r => this.selectedMicLabel().match(r));
+  };
 
   hasHmdMicrophone = () => {
-    return !!(this.state.micDevices.find(d => HMD_MIC_REGEXES.find(r => d.label.match(r))));
-  }
+    return !!this.state.micDevices.find(d => HMD_MIC_REGEXES.find(r => d.label.match(r)));
+  };
 
   selectedMicLabel = () => {
-    return (this.state.mediaStream
-             && this.state.mediaStream.getAudioTracks().length > 0
-             && this.state.mediaStream.getAudioTracks()[0].label) || "";
-  }
+    return (
+      (this.state.mediaStream &&
+        this.state.mediaStream.getAudioTracks().length > 0 &&
+        this.state.mediaStream.getAudioTracks()[0].label) ||
+      ""
+    );
+  };
 
   selectedMicDeviceId = () => {
     return this.state.micDevices.filter(d => d.label === this.selectedMicLabel).map(d => d.deviceId)[0];
-  }
+  };
 
   onAudioReadyButton = () => {
     this.props.enterScene(this.state.mediaStream, this.state.enterInVR, this.state.janusRoomId);
@@ -341,17 +383,17 @@ class UIRoot extends Component {
 
     if (mediaStream) {
       if (mediaStream.getAudioTracks().length > 0) {
-        console.log(`Using microphone: ${mediaStream.getAudioTracks()[0].label}`)
+        console.log(`Using microphone: ${mediaStream.getAudioTracks()[0].label}`);
       }
 
       if (mediaStream.getVideoTracks().length > 0) {
-        console.log('Screen sharing enabled.')
+        console.log("Screen sharing enabled.");
       }
     }
 
     this.stopTestTone();
     this.setState({ entryStep: ENTRY_STEPS.finished });
-  }
+  };
 
   render() {
     if (!this.state.initialEnvironmentLoaded || !this.state.availableVREntryTypes || !this.state.janusRoomId) {
@@ -360,7 +402,7 @@ class UIRoot extends Component {
           <div className="loading-panel">
             <div className="loader-wrap">
               <div className="loader">
-                <div className="loader-center"/>
+                <div className="loader-center" />
               </div>
             </div>
             <div className="loading-panel__title">
@@ -379,7 +421,7 @@ class UIRoot extends Component {
               <b>moz://a</b> duck
             </div>
             <div className="loading-panel__subtitle">
-              <FormattedMessage id="exit.subtitle"/>
+              <FormattedMessage id="exit.subtitle" />
             </div>
           </div>
         </IntlProvider>
@@ -388,125 +430,195 @@ class UIRoot extends Component {
 
     const daydreamMaybeSubtitle = messages["entry.daydream-via-chrome"];
 
-    const entryPanel = this.state.entryStep === ENTRY_STEPS.start
-    ? (
-      <div className="entry-panel">
-        <TwoDEntryButton onClick={this.enter2D}/>
-        { this.state.availableVREntryTypes.generic !== VR_DEVICE_AVAILABILITY.no && <GenericEntryButton onClick={this.enterVR}/> }
-        { this.state.availableVREntryTypes.gearvr !== VR_DEVICE_AVAILABILITY.no && <GearVREntryButton onClick={this.enterGearVR}/> }
-        { this.state.availableVREntryTypes.daydream !== VR_DEVICE_AVAILABILITY.no && 
+    const entryPanel =
+      this.state.entryStep === ENTRY_STEPS.start ? (
+        <div className="entry-panel">
+          <TwoDEntryButton onClick={this.enter2D} />
+          {this.state.availableVREntryTypes.generic !== VR_DEVICE_AVAILABILITY.no && (
+            <GenericEntryButton onClick={this.enterVR} />
+          )}
+          {this.state.availableVREntryTypes.gearvr !== VR_DEVICE_AVAILABILITY.no && (
+            <GearVREntryButton onClick={this.enterGearVR} />
+          )}
+          {this.state.availableVREntryTypes.daydream !== VR_DEVICE_AVAILABILITY.no && (
             <DaydreamEntryButton
               onClick={this.enterDaydream}
-              subtitle={this.state.availableVREntryTypes.daydream == VR_DEVICE_AVAILABILITY.maybe ? daydreamMaybeSubtitle : "" }/> }
-        { this.state.availableVREntryTypes.cardboard !== VR_DEVICE_AVAILABILITY.no &&
-          (<div className="entry-panel__secondary" onClick={this.enterVR}><FormattedMessage id="entry.cardboard"/></div>) }
-      </div>
-    ) : null;
+              subtitle={
+                this.state.availableVREntryTypes.daydream == VR_DEVICE_AVAILABILITY.maybe ? daydreamMaybeSubtitle : ""
+              }
+            />
+          )}
+          {this.state.availableVREntryTypes.cardboard !== VR_DEVICE_AVAILABILITY.no && (
+            <div className="entry-panel__secondary" onClick={this.enterVR}>
+              <FormattedMessage id="entry.cardboard" />
+            </div>
+          )}
+        </div>
+      ) : null;
 
-    const micPanel = this.state.entryStep === ENTRY_STEPS.mic_grant || this.state.entryStep == ENTRY_STEPS.mic_granted
-    ? (
+    const micPanel =
+      this.state.entryStep === ENTRY_STEPS.mic_grant || this.state.entryStep == ENTRY_STEPS.mic_granted ? (
         <div className="mic-grant-panel">
           <div className="mic-grant-panel__title">
-            <FormattedMessage id={ this.state.entryStep == ENTRY_STEPS.mic_grant ? "audio.grant-title" : "audio.granted-title" }/>
+            <FormattedMessage
+              id={this.state.entryStep == ENTRY_STEPS.mic_grant ? "audio.grant-title" : "audio.granted-title"}
+            />
           </div>
           <div className="mic-grant-panel__subtitle">
-            <FormattedMessage id={ this.state.entryStep == ENTRY_STEPS.mic_grant ? "audio.grant-subtitle" : "audio.granted-subtitle" }/>
+            <FormattedMessage
+              id={this.state.entryStep == ENTRY_STEPS.mic_grant ? "audio.grant-subtitle" : "audio.granted-subtitle"}
+            />
           </div>
           <div className="mic-grant-panel__icon">
-          { 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"/>) :
-            (<img onClick={this.onMicGrantButton} src="../assets/images/mic_granted.png" srcSet="../assets/images/mic_granted@2x.png 2x" className="mic-grant-panel__icon"/>)}
+            {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"
+              />
+            ) : (
+              <img
+                onClick={this.onMicGrantButton}
+                src="../assets/images/mic_granted.png"
+                srcSet="../assets/images/mic_granted@2x.png 2x"
+                className="mic-grant-panel__icon"
+              />
+            )}
           </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" }/>
+            <FormattedMessage
+              id={this.state.entryStep == ENTRY_STEPS.mic_grant ? "audio.grant-next" : "audio.granted-next"}
+            />
           </div>
         </div>
       ) : null;
 
     const maxLevelHeight = 111;
-    const micClip = { clip: `rect(${maxLevelHeight - Math.floor(this.state.micLevel * maxLevelHeight)}px, 111px, 111px, 0px)` };
+    const micClip = {
+      clip: `rect(${maxLevelHeight - Math.floor(this.state.micLevel * maxLevelHeight)}px, 111px, 111px, 0px)`
+    };
     const speakerClip = { clip: `rect(${this.state.tonePlaying ? 0 : maxLevelHeight}px, 111px, 111px, 0px)` };
 
-    const audioSetupPanel = this.state.entryStep === ENTRY_STEPS.audio_setup
-    ? (
+    const audioSetupPanel =
+      this.state.entryStep === ENTRY_STEPS.audio_setup ? (
         <div className="audio-setup-panel">
           <div className="audio-setup-panel__title">
-            <FormattedMessage id="audio.title"/>
+            <FormattedMessage id="audio.title" />
           </div>
           <div className="audio-setup-panel__subtitle">
-            { (mobiledetect.mobile() || this.state.enterInVR) && (<FormattedMessage id={ mobiledetect.mobile() ? "audio.subtitle-mobile" : "audio.subtitle-desktop" }/>) }
+            {(mobiledetect.mobile() || this.state.enterInVR) && (
+              <FormattedMessage id={mobiledetect.mobile() ? "audio.subtitle-mobile" : "audio.subtitle-desktop"} />
+            )}
           </div>
           <div className="audio-setup-panel__levels">
             <div className="audio-setup-panel__levels__mic">
-              <img src="../assets/images/mic_level.png" srcSet="../assets/images/mic_level@2x.png 2x" className="audio-setup-panel__levels__mic_icon"/>
-              <img src="../assets/images/level_fill.png" srcSet="../assets/images/level_fill@2x.png 2x" className="audio-setup-panel__levels__level" style={ micClip }/>
+              <img
+                src="../assets/images/mic_level.png"
+                srcSet="../assets/images/mic_level@2x.png 2x"
+                className="audio-setup-panel__levels__mic_icon"
+              />
+              <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">
-              <img src="../assets/images/speaker_level.png" srcSet="../assets/images/speaker_level@2x.png 2x" className="audio-setup-panel__levels__speaker_icon"/>
-              <img src="../assets/images/level_fill.png" srcSet="../assets/images/level_fill@2x.png 2x" className="audio-setup-panel__levels__level" style={ speakerClip }/>
+              <img
+                src="../assets/images/speaker_level.png"
+                srcSet="../assets/images/speaker_level@2x.png 2x"
+                className="audio-setup-panel__levels__speaker_icon"
+              />
+              <img
+                src="../assets/images/level_fill.png"
+                srcSet="../assets/images/level_fill@2x.png 2x"
+                className="audio-setup-panel__levels__level"
+                style={speakerClip}
+              />
             </div>
           </div>
           <div className="audio-setup-panel__device-chooser">
-            <select className="audio-setup-panel__device-chooser__dropdown" value={this.selectedMicDeviceId()} onChange={this.micDeviceChanged}>
-              { this.state.micDevices.map(d => (<option key={ d.deviceId } value={ d.deviceId }>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{d.label}</option>)) }
+            <select
+              className="audio-setup-panel__device-chooser__dropdown"
+              value={this.selectedMicDeviceId()}
+              onChange={this.micDeviceChanged}
+            >
+              {this.state.micDevices.map(d => (
+                <option key={d.deviceId} value={d.deviceId}>
+                  &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{d.label}
+                </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"/>
+              <img src="../assets/images/mic_small.png" srcSet="../assets/images/mic_small@2x.png 2x" />
             </div>
           </div>
-          { this.shouldShowHmdMicWarning() &&
-            (<div className="audio-setup-panel__hmd-mic-warning">
-              <img src="../assets/images/warning_icon.png" srcSet="../assets/images/warning_icon@2x.png 2x"
-                   className="audio-setup-panel__hmd-mic-warning__icon"/>
+          {this.shouldShowHmdMicWarning() && (
+            <div className="audio-setup-panel__hmd-mic-warning">
+              <img
+                src="../assets/images/warning_icon.png"
+                srcSet="../assets/images/warning_icon@2x.png 2x"
+                className="audio-setup-panel__hmd-mic-warning__icon"
+              />
               <span className="audio-setup-panel__hmd-mic-warning__label">
-                <FormattedMessage id="audio.hmd-mic-warning"/>
+                <FormattedMessage id="audio.hmd-mic-warning" />
               </span>
-            </div>) }
+            </div>
+          )}
           <div className="audio-setup-panel__enter-button" onClick={this.onAudioReadyButton}>
-            <FormattedMessage id="audio.enter-now"/>
+            <FormattedMessage id="audio.enter-now" />
           </div>
         </div>
       ) : null;
 
-    const dialogContents = this.isWaitingForAutoExit() ?
-      (<AutoExitWarning secondsRemaining={this.state.secondsRemainingBeforeAutoExit} onCancel={this.endAutoExitTimer} />) :
-      (
-        <div className="entry-dialog">
-          <ProfileInfoHeader name={this.props.store.state.profile.display_name} onClick={(() => this.setState({showProfileEntry: true })) }/>
-          {entryPanel}
-          {micPanel}
-          {audioSetupPanel}
-        </div>
-      );
+    const dialogContents = this.isWaitingForAutoExit() ? (
+      <AutoExitWarning secondsRemaining={this.state.secondsRemainingBeforeAutoExit} onCancel={this.endAutoExitTimer} />
+    ) : (
+      <div className="entry-dialog">
+        <ProfileInfoHeader
+          name={this.props.store.state.profile.display_name}
+          onClick={() => this.setState({ showProfileEntry: true })}
+        />
+        {entryPanel}
+        {micPanel}
+        {audioSetupPanel}
+      </div>
+    );
 
-    const dialogClassNames = classNames({
-      'ui-dialog': true,
-      'ui-dialog--darkened': this.state.entryStep !== ENTRY_STEPS.finished
+    const dialogClassNames = classNames("ui-dialog", {
+      "ui-dialog--darkened": this.state.entryStep !== ENTRY_STEPS.finished
     });
 
-    const dialogBoxClassNames = classNames({ 'ui-dialog-box': true });
+    const dialogBoxClassNames = classNames("ui-interactive", "ui-dialog-box");
 
     const dialogBoxContentsClassNames = classNames({
-      'ui-dialog-box-contents': true,
-      'ui-dialog-box-contents--backgrounded': this.state.showProfileEntry
+      "ui-dialog-box-contents": true,
+      "ui-dialog-box-contents--backgrounded": this.state.showProfileEntry
     });
 
     return (
       <IntlProvider locale={lang} messages={messages}>
-        <div className={dialogClassNames}>
-          {
-            (this.state.entryStep !== ENTRY_STEPS.finished || this.isWaitingForAutoExit()) &&
-            (
+        <div className="ui">
+          <div className={dialogClassNames}>
+            {(this.state.entryStep !== ENTRY_STEPS.finished || this.isWaitingForAutoExit()) && (
               <div className={dialogBoxClassNames}>
-                <div className={dialogBoxContentsClassNames}>
-                  {dialogContents}
-                </div>
+                <div className={dialogBoxContentsClassNames}>{dialogContents}</div>
 
                 {this.state.showProfileEntry && (
-                  <ProfileEntryPanel finished={this.onProfileFinished} store={this.props.store}/>)}
+                  <ProfileEntryPanel finished={this.onProfileFinished} store={this.props.store} />
+                )}
               </div>
-            )
-          }
+            )}
+          </div>
+          {this.state.entryStep === ENTRY_STEPS.finished ? (
+            <TwoDHUD
+              name={this.props.store.state.profile.display_name}
+              muted={this.state.muted}
+              onToggleMute={this.toggleMute}
+            />
+          ) : null}
         </div>
       </IntlProvider>
     );
diff --git a/src/systems/app-mode.js b/src/systems/app-mode.js
new file mode 100644
index 0000000000000000000000000000000000000000..421bf48abbab344b877f9805803a7dfe30472336
--- /dev/null
+++ b/src/systems/app-mode.js
@@ -0,0 +1,226 @@
+/* global AFRAME, console, setTimeout, clearTimeout */
+
+const AppModes = Object.freeze({ DEFAULT: "default", HUD: "hud" });
+
+/**
+ * Simple system for keeping track of a modal app state
+ */
+AFRAME.registerSystem("app-mode", {
+  init() {
+    this.setMode(AppModes.DEFAULT);
+  },
+
+  setMode(newMode) {
+    if (Object.values(AppModes).includes(newMode) && newMode !== this.mode) {
+      this.mode = newMode;
+      this.el.emit("app-mode-change", { mode: this.mode });
+    }
+  }
+});
+
+/**
+ * Toggle the isPlaying state of a component based on app mode
+ */
+AFRAME.registerComponent("app-mode-toggle-playing", {
+  multiple: true,
+  schema: {
+    mode: { type: "string" },
+    invert: { type: "boolean", default: false }
+  },
+
+  init() {
+    const AppModeSystem = this.el.sceneEl.systems["app-mode"];
+    this.el.sceneEl.addEventListener("app-mode-change", e => {
+      this.updateComponentState(e.detail.mode === this.data.mode);
+    });
+    this.updateComponentState(AppModeSystem.mode === this.data.mode);
+  },
+
+  updateComponentState(isModeActive) {
+    const componentName = this.id;
+    this.el.components[componentName][isModeActive !== this.data.invert ? "play" : "pause"]();
+  }
+});
+
+/**
+ * Toggle a boolean property of a component based on app mode
+ */
+AFRAME.registerComponent("app-mode-toggle-attribute", {
+  multiple: true,
+  schema: {
+    mode: { type: "string" },
+    invert: { type: "boolean", default: false },
+    property: { type: "string" }
+  },
+
+  init() {
+    const AppModeSystem = this.el.sceneEl.systems["app-mode"];
+    this.el.sceneEl.addEventListener("app-mode-change", e => {
+      this.updateComponentState(e.detail.mode === this.data.mode);
+    });
+    this.updateComponentState(AppModeSystem.mode === this.data.mode);
+  },
+
+  updateComponentState(isModeActive) {
+    const componentName = this.id;
+    this.el.setAttribute(componentName, this.data.property, isModeActive !== this.data.invert);
+  }
+});
+
+/**
+ * Toggle aframe input mappings action set based on app mode
+ */
+AFRAME.registerComponent("app-mode-input-mappings", {
+  schema: {
+    modes: { default: [] },
+    actionSets: { default: [] }
+  },
+  init() {
+    this.el.sceneEl.addEventListener("app-mode-change", e => {
+      const { modes, actionSets } = this.data;
+      const idx = modes.indexOf(e.detail.mode);
+      if (idx != -1 && modes[idx] && actionSets[idx] && AFRAME.inputActions[actionSets[idx]]) {
+        // TODO: this assumes full control over current action set reguardless of what else might be manipulating it, this is obviously wrong
+        AFRAME.currentInputMapping = actionSets[idx];
+      } else {
+        console.error(`no valid action set for ${e.detail.mode}`);
+      }
+    });
+  }
+});
+
+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: 1 }, // distance from hud below head,
+    lookCutoff: { default: -25 }, // angle at which the hud should be "on",
+    animRange: { default: 30 }, // degrees over which to animate the hud into view
+    yawCutoff: { default: 100 } // yaw degrees at wich the hud should reoirent even if the user is looking down
+  },
+  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 "up", 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(pitch - lookCutoff, 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 - offset * (1 - t);
+    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
+ */
+AFRAME.registerComponent("vr-mode-toggle-visibility", {
+  schema: {
+    invert: { type: "boolean", default: false }
+  },
+
+  init() {
+    this.updateComponentState = this.updateComponentState.bind(this);
+  },
+
+  play() {
+    this.updateComponentState();
+    this.el.sceneEl.addEventListener("enter-vr", this.updateComponentState);
+    this.el.sceneEl.addEventListener("exit-vr", this.updateComponentState);
+  },
+
+  pause() {
+    this.el.sceneEl.removeEventListener("enter-vr", this.updateComponentState);
+    this.el.sceneEl.removeEventListener("exit-vr", this.updateComponentState);
+  },
+
+  updateComponentState(i) {
+    const inVRMode = this.el.sceneEl.is("vr-mode");
+    this.el.setAttribute("visible", inVRMode !== this.data.invert);
+  }
+});
+
+/**
+ * Toggle the isPlaying state of a component based on app mode
+ */
+AFRAME.registerComponent("vr-mode-toggle-playing", {
+  multiple: true,
+  schema: {
+    invert: { type: "boolean", default: false }
+  },
+
+  init() {
+    this.updateComponentState = this.updateComponentState.bind(this);
+  },
+
+  play() {
+    this.updateComponentState();
+    this.el.sceneEl.addEventListener("enter-vr", this.updateComponentState);
+    this.el.sceneEl.addEventListener("exit-vr", this.updateComponentState);
+  },
+
+  pause() {
+    this.el.sceneEl.removeEventListener("enter-vr", this.updateComponentState);
+    this.el.sceneEl.removeEventListener("exit-vr", this.updateComponentState);
+  },
+
+  updateComponentState(i) {
+    const componentName = this.id;
+    const inVRMode = this.el.sceneEl.is("vr-mode");
+    this.el.components[componentName][inVRMode !== this.data.invert ? "play" : "pause"]();
+  }
+});
diff --git a/yarn.lock b/yarn.lock
index 33240630b4fbc8aae1eabc53fb846f7179a5f5db..cce545356bdd3a422811544e84dc33aafcbf63db 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -176,9 +176,9 @@ aframe-physics-system@^1.4.3:
     three-to-cannon "^1.2.0"
     webworkify "^1.4.0"
 
-"aframe-teleport-controls@https://github.com/netpro2k/aframe-teleport-controls#feature/teleport-origin":
-  version "0.3.0"
-  resolved "https://github.com/netpro2k/aframe-teleport-controls#41fe311d3123503ba44761acce69d0f0634139cc"
+"aframe-teleport-controls@https://github.com/netpro2k/aframe-teleport-controls#feature/pauseable":
+  version "0.3.2"
+  resolved "https://github.com/netpro2k/aframe-teleport-controls#7f67003dd3bd1348357fbf89aaeed916ef2d4016"
 
 "aframe-xr@github:brianpeiris/aframe-xr#3162aed":
   version "0.0.9"
@@ -2604,14 +2604,14 @@ dom-walk@^0.1.0:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/dom-walk/-/dom-walk-0.1.1.tgz#672226dc74c8f799ad35307df936aba11acd6018"
 
-domain-browser@^1.1.1, domain-browser@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda"
-
-domain-browser@~1.1.0:
+domain-browser@^1.1.1, domain-browser@~1.1.0:
   version "1.1.7"
   resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.1.7.tgz#867aa4b093faa05f1de08c06f4d7b21fdf8698bc"
 
+domain-browser@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda"
+
 domelementtype@1:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.0.tgz#b17aed82e8ab59e52dd9c19b1756e0fc187204c2"