diff --git a/package.json b/package.json
index ad584f83bcf0827ec6c4f2914bf9e38abc20234b..8b2851b5edcd8fc13942b1dc88882760fe7c4728 100644
--- a/package.json
+++ b/package.json
@@ -34,10 +34,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": "github:mozillareality/networked-aframe#mr-social-client/master",
+    "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",
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/gltf-model-plus.js b/src/components/gltf-model-plus.js
index d9ff8d8ff722388d3b0a63f4731d5541e91bde5e..41b6b50200ad273fdc5729866a9809245c3c23ff 100644
--- a/src/components/gltf-model-plus.js
+++ b/src/components/gltf-model-plus.js
@@ -154,8 +154,8 @@ function attachTemplate(root, { selector, templateRoot }) {
     }
 
     // Append all child elements
-    for (const child of root.children) {
-      el.appendChild(child);
+    while (root.children.length > 0) {
+      el.appendChild(root.children[0]);
     }
   }
 }
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/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/hub.html b/src/hub.html
index 0575e5b0156ddeac6b68a603c6fe53690c56c38f..0f6df33ece622b7d5f3bea8ff007628213623893 100644
--- a/src/hub.html
+++ b/src/hub.html
@@ -63,7 +63,7 @@
 
                     <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">
@@ -157,7 +157,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"
@@ -227,8 +227,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>
 
@@ -275,7 +278,12 @@
         ></a-entity>
 
         <!-- Environment -->
-        <a-entity id="environment-root" position="0 0 0" nav-mesh-helper></a-entity>
+        <a-entity 
+            id="environment-root" 
+            nav-mesh-helper
+            static-body="shape: none;"
+            class="collidable"
+        ></a-entity>
 
         <a-entity
             id="skybox"
@@ -294,23 +302,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 6f80de46a67fb1165190874a46324505ae77a565..89c03be300ad7bf9122ef0837cb6546f11a05ea0 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,7 +18,7 @@ import "aframe-rounded";
 import "webrtc-adapter";
 
 import trackpad_dpad4 from "./behaviours/trackpad-dpad4";
-import { joystick_dpad4 } from "./behaviours/joystick-dpad4";
+import joystick_dpad4 from "./behaviours/joystick-dpad4";
 import { PressedMove } from "./activators/pressedmove";
 import { ReverseY } from "./activators/reversey";
 import "./activators/shortpress";
@@ -37,12 +40,12 @@ 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";
@@ -50,6 +53,7 @@ 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";
@@ -106,6 +110,7 @@ AFRAME.registerInputMappings(inputConfig, true);
 
 const store = new Store();
 const concurrentLoadDetector = new ConcurrentLoadDetector();
+const hubChannel = new HubChannel(store);
 
 concurrentLoadDetector.start();
 
@@ -113,6 +118,7 @@ concurrentLoadDetector.start();
 store.update({ profile: { ...generateDefaultProfile(), ...(store.state.profile || {}) } });
 
 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);
@@ -150,7 +156,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", {});
   }
 
@@ -186,6 +192,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) {
@@ -276,15 +288,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;
+      uiRoot.setState({ 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/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/storage/store.js b/src/storage/store.js
index b9f7366abf0aa01d0cec7836bcef541bf3fe8ee8..2c79a06604a08b0e1a72e1b3fca2a2d4264d80c5 100644
--- a/src/storage/store.js
+++ b/src/storage/store.js
@@ -28,7 +28,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
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/yarn.lock b/yarn.lock
index 9e4e6fbae46ef483424f8b4ada1dadce72744951..2f53dc8b77829473fcf0cc14e978f0eb226eaaaa 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:
@@ -2007,14 +2007,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"
@@ -3072,7 +3076,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:
@@ -3278,6 +3317,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"
@@ -3971,7 +4022,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:
@@ -3980,6 +4031,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"
@@ -5244,6 +5304,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"
@@ -5345,9 +5415,9 @@ neo-async@^2.5.0:
   version "2.5.0"
   resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.5.0.tgz#76b1c823130cca26acfbaccc8fbaf0a2fa33b18f"
 
-"networked-aframe@github:mozillareality/networked-aframe#mr-social-client/master":
+"networked-aframe@https://github.com/mozillareality/networked-aframe#mr-social-client/master":
   version "0.6.1"
-  resolved "https://codeload.github.com/mozillareality/networked-aframe/tar.gz/69be0e7e5f66070526c8240cb795b9e88da971a9"
+  resolved "https://github.com/mozillareality/networked-aframe#69be0e7e5f66070526c8240cb795b9e88da971a9"
   dependencies:
     easyrtc "1.1.0"
     express "^4.10.7"
@@ -5950,6 +6020,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"
@@ -6321,7 +6395,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:
@@ -7089,7 +7163,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:
@@ -7465,7 +7539,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"
 
@@ -7897,7 +7975,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: