diff --git a/scripts/hab-build-and-push.sh b/scripts/hab-build-and-push.sh
index f9ffb81ad1ef8e780bdc4efd8499eee9c0eab4c4..1f0b129f00851cfae504fc347b17adfdde7ffb3f 100755
--- a/scripts/hab-build-and-push.sh
+++ b/scripts/hab-build-and-push.sh
@@ -31,6 +31,7 @@ npm rebuild node-sass # HACK sometimes node-sass build fails
 npm run build
 mkdir dist/pages
 mv dist/*.html dist/pages
+mv dist/hub.service.js dist/pages
 
 aws s3 sync --acl public-read --cache-control "max-age=31556926" dist/assets "$TARGET_S3_URL/assets"
 aws s3 sync --acl public-read --cache-control "no-cache" --delete dist/pages "$TARGET_S3_URL/pages/latest"
diff --git a/src/assets/stylesheets/entry.scss b/src/assets/stylesheets/entry.scss
index f0424741082257a992e0adfd24de80703d1899c7..d94b199b07ffcaf6b6af11904fb8a779804ea13a 100644
--- a/src/assets/stylesheets/entry.scss
+++ b/src/assets/stylesheets/entry.scss
@@ -184,4 +184,24 @@
     cursor: pointer;
     color: $dark-grey-text;
   }
+
+  :local(.subscribe) {
+    margin-bottom: 24px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    color: $darker-grey;
+
+    input {
+      @extend %checkbox;
+    }
+
+    input:checked {
+      @extend %checkbox-checked;
+    }
+
+    label {
+      margin-right: 8px;
+    }
+  }
 }
diff --git a/src/assets/stylesheets/hub-create.scss b/src/assets/stylesheets/hub-create.scss
index bc9145df6e1246e5a34e60f29efc7fffe7297414..2af9cb72e0c16e35f58b5df5ffd16faadd90dca0 100644
--- a/src/assets/stylesheets/hub-create.scss
+++ b/src/assets/stylesheets/hub-create.scss
@@ -51,12 +51,12 @@
       border-radius: 0px 0px 14px 14px;
 
       &::selection {
-        background-color: #2F80ED;
+        background-color: $bright-blue;
         color: white;
       }
 
       &::-moz-selection {
-        background-color: #2F80ED;
+        background-color: $bright-blue;
         color: white;
       }
 
diff --git a/src/assets/stylesheets/index.scss b/src/assets/stylesheets/index.scss
index 66595775efe1e43c7a835987940ff288eee63ef1..46029a3b32be5675fd1448ceb65ca9375e6ddd66 100644
--- a/src/assets/stylesheets/index.scss
+++ b/src/assets/stylesheets/index.scss
@@ -172,7 +172,7 @@ body {
 
   :local(.create) {
     padding: 2.1em;
-    padding-bottom: 3.5vw;
+    padding-bottom: 2vw;
     position: relative;
 
     @media (max-width: 768px) {
@@ -189,6 +189,17 @@ body {
       @extend %action-button;
     }
   }
+
+  :local(.spoke-button) {
+    display: flex;
+    justify-content: center;
+
+    a {
+      margin-top: 16px;
+      @extend %action-button;
+      background: $spoke-action-color;
+    }
+  }
 }
 
 :local(.footer-content) {
diff --git a/src/assets/stylesheets/shared.scss b/src/assets/stylesheets/shared.scss
index 79a12fc6febb433dfa5453fa2729454971cbb690..d44b6b31916977101aea39ee8824cee35e388559 100644
--- a/src/assets/stylesheets/shared.scss
+++ b/src/assets/stylesheets/shared.scss
@@ -10,10 +10,12 @@ $light-grey: lightgrey;
 $dark-grey: rgba(128, 128, 128, 1.0);
 $darker-grey: rgba(64, 64, 64, 1.0);
 $darkest-grey: rgba(32, 32, 32, 1.0);
+$bright-blue: #2F80ED;
 $action-color: #FF3464;
 $action-color-light: #FF74A4;
 $action-color-transparent: rgba(255, 52, 100, 0.9);
 $hud-panel-background: rgba(79, 79, 79, 0.45);
+$spoke-action-color: $bright-blue;
 
 %unselectable {
   -moz-user-select: none;
@@ -153,16 +155,15 @@ $hud-panel-background: rgba(79, 79, 79, 0.45);
   -webkit-appearance: none;
   width: 2em;
   height: 2em;
-  border: 1px solid #e2e2e2;
-  border-radius: 9px;
+  border: 3px solid #aaa;
+  border-radius: 6px;
   vertical-align: sub;
   margin: 0 0.6em
 }
 
 %checkbox-checked {
-  border: 9px double #aaa;
-  outline: 9px solid #aaa;
-  outline-offset: -18px;
+  outline: 9px solid $action-color;
+  outline-offset: -15px;
 }
 
 %background-agnostic {
diff --git a/src/assets/stylesheets/spoke.scss b/src/assets/stylesheets/spoke.scss
index 68f850879027e250f7bcce75a16a88692cf4a6e3..0219502e7a00014e7cbb07530ca3a78e06353b73 100644
--- a/src/assets/stylesheets/spoke.scss
+++ b/src/assets/stylesheets/spoke.scss
@@ -1,7 +1,6 @@
 @import 'shared';
 @import 'loader';
 
-$spoke-action-color: #2F80ED;
 $breakpoint: 1280px;
 $mobile-breakpoint-width: 450px;
 
diff --git a/src/assets/translations.data.json b/src/assets/translations.data.json
index 47fef3295a0b67e0216c506bb504b8e96c9bf2d0..819a5a04649cf73d1cc12f9d8b64f6cd1b5ababd 100644
--- a/src/assets/translations.data.json
+++ b/src/assets/translations.data.json
@@ -32,6 +32,7 @@
     "entry.enable-screen-sharing": "Share my desktop",
     "entry.return-to-vr": "Enter in VR",
     "entry.lobby": "Lobby",
+    "entry.notify_me": "Notify me when others are here",
     "profile.save": "Accept",
     "profile.display_name.validation_warning": "Alphanumerics and hyphens. At least 3 characters, no more than 32",
     "profile.header": "Name & Avatar",
@@ -68,6 +69,7 @@
     "home.create_name.validation_warning": "Invalid name, limited to 4 to 64 characters and limited symbols.",
     "home.join_us": "Join the Conversation",
     "home.join_room": "Join Room",
+    "home.create_with_spoke": "Create a Scene",
     "home.report_issue": "Report Issues",
     "home.source_link": "Source",
     "home.spoke_link": "Spoke",
diff --git a/src/components/action-to-event.js b/src/components/action-to-event.js
index ee6f397445fea8d556f268ca81ea978f366df6d0..8bcd22e43dcbc30759f7ec3ec17692f0bf75a161 100644
--- a/src/components/action-to-event.js
+++ b/src/components/action-to-event.js
@@ -8,7 +8,7 @@ AFRAME.registerComponent("action-to-event", {
 
   tick() {
     const userinput = AFRAME.scenes[0].systems.userinput;
-    if (userinput.readFrameValueAtPath(this.data.path)) {
+    if (userinput.get(this.data.path)) {
       this.el.emit(this.data.event);
     }
   }
diff --git a/src/components/camera-tool.js b/src/components/camera-tool.js
index 1e19cf492e33d69ac475211ea7f21bab6d10db58..2972d7fcd63b876aeaf22db191d998edb3183351 100644
--- a/src/components/camera-tool.js
+++ b/src/components/camera-tool.js
@@ -110,7 +110,7 @@ AFRAME.registerComponent("camera-tool", {
     const grabber = this.el.components.grabbable.grabbers[0];
     if (grabber && !!pathsMap[grabber.id]) {
       const paths = pathsMap[grabber.id];
-      if (AFRAME.scenes[0].systems.userinput.readFrameValueAtPath(paths.takeSnapshot)) {
+      if (AFRAME.scenes[0].systems.userinput.get(paths.takeSnapshot)) {
         this.takeSnapshotNextTick = true;
       }
     }
diff --git a/src/components/character-controller.js b/src/components/character-controller.js
index 910123ba8bb2f5e54e64037ac478a1ac2e57e577..083f47e195e7453136c958ecad62762be882c614 100644
--- a/src/components/character-controller.js
+++ b/src/components/character-controller.js
@@ -45,8 +45,6 @@ AFRAME.registerComponent("character-controller", {
     const eventSrc = this.el.sceneEl;
     eventSrc.addEventListener("move", this.setAccelerationInput);
     eventSrc.addEventListener("rotateY", this.setAngularVelocity);
-    eventSrc.addEventListener("snap_rotate_left", this.snapRotateLeft);
-    eventSrc.addEventListener("snap_rotate_right", this.snapRotateRight);
     eventSrc.addEventListener("teleported", this.handleTeleport);
   },
 
@@ -54,8 +52,6 @@ AFRAME.registerComponent("character-controller", {
     const eventSrc = this.el.sceneEl;
     eventSrc.removeEventListener("move", this.setAccelerationInput);
     eventSrc.removeEventListener("rotateY", this.setAngularVelocity);
-    eventSrc.removeEventListener("snap_rotate_left", this.snapRotateLeft);
-    eventSrc.removeEventListener("snap_rotate_right", this.snapRotateRight);
     eventSrc.removeEventListener("teleported", this.handleTeleport);
     this.reset();
   },
@@ -78,10 +74,12 @@ AFRAME.registerComponent("character-controller", {
 
   snapRotateLeft: function() {
     this.pendingSnapRotationMatrix.copy(this.leftRotationMatrix);
+    this.el.emit("snap_rotate_left");
   },
 
   snapRotateRight: function() {
     this.pendingSnapRotationMatrix.copy(this.rightRotationMatrix);
+    this.el.emit("snap_rotate_right");
   },
 
   handleTeleport: function(event) {
@@ -119,13 +117,13 @@ AFRAME.registerComponent("character-controller", {
       root.updateMatrix();
 
       const userinput = AFRAME.scenes[0].systems.userinput;
-      if (userinput.readFrameValueAtPath(paths.actions.snapRotateLeft)) {
+      if (userinput.get(paths.actions.snapRotateLeft)) {
         this.snapRotateLeft();
       }
-      if (userinput.readFrameValueAtPath(paths.actions.snapRotateRight)) {
+      if (userinput.get(paths.actions.snapRotateRight)) {
         this.snapRotateRight();
       }
-      const acc = userinput.readFrameValueAtPath(paths.actions.characterAcceleration);
+      const acc = userinput.get(paths.actions.characterAcceleration);
       if (acc) {
         this.accelerationInput.set(
           this.accelerationInput.x + acc[0],
@@ -145,7 +143,7 @@ AFRAME.registerComponent("character-controller", {
       this.updateVelocity(deltaSeconds);
       this.accelerationInput.set(0, 0, 0);
 
-      const boost = userinput.readFrameValueAtPath(paths.actions.boost) ? 2 : 1;
+      const boost = userinput.get(paths.actions.boost) ? 2 : 1;
       move.makeTranslation(
         this.velocity.x * distance * boost,
         this.velocity.y * distance * boost,
diff --git a/src/components/cursor-controller.js b/src/components/cursor-controller.js
index c5113dba822b23944026cff06b41497deecf55f0..1b095fc05948d88e38cbbcebd8b3141046ffb69f 100644
--- a/src/components/cursor-controller.js
+++ b/src/components/cursor-controller.js
@@ -94,8 +94,8 @@ AFRAME.registerComponent("cursor-controller", {
       }
 
       const userinput = AFRAME.scenes[0].systems.userinput;
-      const cursorPose = userinput.readFrameValueAtPath(paths.actions.cursor.pose);
-      const rightHandPose = userinput.readFrameValueAtPath(paths.actions.rightHand.pose);
+      const cursorPose = userinput.get(paths.actions.cursor.pose);
+      const rightHandPose = userinput.get(paths.actions.rightHand.pose);
 
       this.data.cursor.object3D.visible = this.enabled && !!cursorPose;
       this.el.setAttribute("line", "visible", this.enabled && !!rightHandPose);
@@ -119,7 +119,7 @@ AFRAME.registerComponent("cursor-controller", {
 
       const { cursor, near, far, camera, cursorColorHovered, cursorColorUnhovered } = this.data;
 
-      const cursorModDelta = userinput.readFrameValueAtPath(paths.actions.cursor.modDelta);
+      const cursorModDelta = userinput.get(paths.actions.cursor.modDelta);
       if (isGrabbing && cursorModDelta) {
         this.distance = THREE.Math.clamp(this.distance - cursorModDelta, near, far);
       }
diff --git a/src/components/hand-controls2.js b/src/components/hand-controls2.js
index 534f796c45c304097768d5e486cac12db4d76664..3a97888b172687838581fc720f516e50ceb6b248 100644
--- a/src/components/hand-controls2.js
+++ b/src/components/hand-controls2.js
@@ -106,10 +106,10 @@ AFRAME.registerComponent("hand-controls2", {
     const hand = this.data;
     const userinput = AFRAME.scenes[0].systems.userinput;
     const subpath = hand === "left" ? paths.actions.leftHand : paths.actions.rightHand;
-    const hasPose = userinput.readFrameValueAtPath(subpath.pose);
-    const thumb = userinput.readFrameValueAtPath(subpath.thumb);
-    const index = userinput.readFrameValueAtPath(subpath.index);
-    const middleRingPinky = userinput.readFrameValueAtPath(subpath.middleRingPinky);
+    const hasPose = userinput.get(subpath.pose);
+    const thumb = userinput.get(subpath.thumb);
+    const index = userinput.get(subpath.index);
+    const middleRingPinky = userinput.get(subpath.middleRingPinky);
     const pose = this.poseForFingers(thumb, index, middleRingPinky);
     if (pose !== this.pose) {
       this.el.emit("hand-pose", { previous: this.pose, current: pose });
diff --git a/src/components/pinch-to-move.js b/src/components/pinch-to-move.js
index 28dc43cb5d2584532bf549fc3b08aaf01c1ce953..bc5c57a7631e8070c4eaafba13f52c0ceb757bca 100644
--- a/src/components/pinch-to-move.js
+++ b/src/components/pinch-to-move.js
@@ -9,7 +9,7 @@ AFRAME.registerComponent("pinch-to-move", {
   },
   tick() {
     const userinput = AFRAME.scenes[0].systems.userinput;
-    const pinch = userinput.readFrameValueAtPath(paths.device.touchscreen.pinchDelta);
+    const pinch = userinput.get(paths.device.touchscreen.pinchDelta);
     if (pinch) {
       this.axis[1] = pinch * this.data.speed;
       this.el.emit("move", { axis: this.axis });
diff --git a/src/components/pitch-yaw-rotator.js b/src/components/pitch-yaw-rotator.js
index 297bf4f81b5105ca229a5d34bdf527b2389725bb..a2c2ebdead1d580c4480e2578e59c918289941f0 100644
--- a/src/components/pitch-yaw-rotator.js
+++ b/src/components/pitch-yaw-rotator.js
@@ -36,7 +36,7 @@ AFRAME.registerComponent("pitch-yaw-rotator", {
 
   tick() {
     const userinput = AFRAME.scenes[0].systems.userinput;
-    const cameraDelta = userinput.readFrameValueAtPath(paths.actions.cameraDelta);
+    const cameraDelta = userinput.get(paths.actions.cameraDelta);
     let lookX = this.pendingXRotation;
     let lookY = 0;
     if (cameraDelta) {
diff --git a/src/components/super-networked-interactable.js b/src/components/super-networked-interactable.js
index 4c96f2452f9945c0b9bf29be259c487b8d665c3d..1fbbaa37c6f0786b91f9d8cc109df19e0716829d 100644
--- a/src/components/super-networked-interactable.js
+++ b/src/components/super-networked-interactable.js
@@ -98,6 +98,6 @@ AFRAME.registerComponent("super-networked-interactable", {
     if (!(grabber && pathsMap[grabber.id])) return;
 
     const userinput = AFRAME.scenes[0].systems.userinput;
-    this._changeScale(userinput.readFrameValueAtPath(pathsMap[grabber.id].scaleGrabbedGrabbable));
+    this._changeScale(userinput.get(pathsMap[grabber.id].scaleGrabbedGrabbable));
   }
 });
diff --git a/src/components/super-spawner.js b/src/components/super-spawner.js
index a786639e1b828e34d1daf36bedd10e500d275ed1..7ff4e1189f589d9d026ba63e920dfc5d47a78760 100644
--- a/src/components/super-spawner.js
+++ b/src/components/super-spawner.js
@@ -116,8 +116,8 @@ AFRAME.registerComponent("super-spawner", {
 
   async onSpawnEvent() {
     const userinput = AFRAME.scenes[0].systems.userinput;
-    const leftPose = userinput.readFrameValueAtPath(paths.actions.leftHand.pose);
-    const rightPose = userinput.readFrameValueAtPath(paths.actions.rightHand.pose);
+    const leftPose = userinput.get(paths.actions.leftHand.pose);
+    const rightPose = userinput.get(paths.actions.rightHand.pose);
     const controllerCount = leftPose && rightPose ? 2 : leftPose || rightPose ? 1 : 0;
     const using6DOF = controllerCount > 1 && this.el.sceneEl.is("vr-mode");
     const hand = using6DOF ? this.data.superHand : this.data.cursorSuperHand;
diff --git a/src/components/tools/pen.js b/src/components/tools/pen.js
index 0f360e9c8469795977cbbaa78773b1dac15559ee..ed6583aa0f8ccc13b7246e40845e82ef4a045998 100644
--- a/src/components/tools/pen.js
+++ b/src/components/tools/pen.js
@@ -103,20 +103,20 @@ AFRAME.registerComponent("pen", {
     const userinput = AFRAME.scenes[0].systems.userinput;
     if (grabber && pathsMap[grabber.id]) {
       const paths = pathsMap[grabber.id];
-      if (userinput.readFrameValueAtPath(paths.startDrawing)) {
+      if (userinput.get(paths.startDrawing)) {
         this._startDraw();
       }
-      if (userinput.readFrameValueAtPath(paths.stopDrawing)) {
+      if (userinput.get(paths.stopDrawing)) {
         this._endDraw();
       }
-      const penScaleMod = userinput.readFrameValueAtPath(paths.scalePenTip);
+      const penScaleMod = userinput.get(paths.scalePenTip);
       if (penScaleMod) {
         this._changeRadius(penScaleMod);
       }
-      if (userinput.readFrameValueAtPath(paths.penNextColor)) {
+      if (userinput.get(paths.penNextColor)) {
         this._changeColor(1);
       }
-      if (userinput.readFrameValueAtPath(paths.penPrevColor)) {
+      if (userinput.get(paths.penPrevColor)) {
         this._changeColor(-1);
       }
     }
diff --git a/src/hub.js b/src/hub.js
index 720be636d61713970d536fb39b55ab937d52dfb5..a19e4e857cc8f450e9c4471afc305c3cc2bf6a86 100644
--- a/src/hub.js
+++ b/src/hub.js
@@ -72,6 +72,7 @@ import { connectToReticulum } from "./utils/phoenix-utils";
 import { disableiOSZoom } from "./utils/disable-ios-zoom";
 import { proxiedUrlFor } from "./utils/media-utils";
 import SceneEntryManager from "./scene-entry-manager";
+import Subscriptions from "./subscriptions";
 
 import "./systems/nav";
 import "./systems/personal-space-bubble";
@@ -134,7 +135,6 @@ if (!isBotMode && !isTelemetryDisabled) {
 disableiOSZoom();
 
 const concurrentLoadDetector = new ConcurrentLoadDetector();
-
 concurrentLoadDetector.start();
 
 store.init();
@@ -288,6 +288,26 @@ async function runBotMode(scene, entryManager) {
 }
 
 document.addEventListener("DOMContentLoaded", async () => {
+  const hubId = qs.get("hub_id") || document.location.pathname.substring(1).split("/")[0];
+  console.log(`Hub ID: ${hubId}`);
+
+  const subscriptions = new Subscriptions(hubId);
+
+  if (navigator.serviceWorker) {
+    try {
+      navigator.serviceWorker
+        .register("/hub.service.js")
+        .then(() => {
+          navigator.serviceWorker.ready
+            .then(registration => subscriptions.setRegistration(registration))
+            .catch(() => subscriptions.setRegistrationFailed());
+        })
+        .catch(() => subscriptions.setRegistrationFailed());
+    } catch (e) {
+      subscriptions.setRegistrationFailed();
+    }
+  }
+
   const scene = document.querySelector("a-scene");
   const hubChannel = new HubChannel(store);
   const entryManager = new SceneEntryManager(hubChannel);
@@ -298,7 +318,14 @@ document.addEventListener("DOMContentLoaded", async () => {
   window.APP.scene = scene;
 
   registerNetworkSchemas();
-  remountUI({ hubChannel, linkChannel, enterScene: entryManager.enterScene, exitScene: entryManager.exitScene });
+  remountUI({
+    hubChannel,
+    linkChannel,
+    subscriptions,
+    enterScene: entryManager.enterScene,
+    exitScene: entryManager.exitScene,
+    initialIsSubscribed: subscriptions.isSubscribed()
+  });
 
   pollForSupportAvailability(isSupportAvailable => remountUI({ isSupportAvailable }));
 
@@ -346,10 +373,6 @@ document.addEventListener("DOMContentLoaded", async () => {
     }
   });
 
-  // Connect to reticulum over phoenix channels to get hub info.
-  const hubId = qs.get("hub_id") || document.location.pathname.substring(1).split("/")[0];
-  console.log(`Hub ID: ${hubId}`);
-
   const socket = connectToReticulum(isDebug);
   remountUI({ sessionId: socket.params().session_id });
 
@@ -359,13 +382,27 @@ document.addEventListener("DOMContentLoaded", async () => {
     hmd: availableVREntryTypes.isInHMD
   };
 
-  const joinPayload = { profile: store.state.profile, context };
+  // Reticulum global channel
+  const retPhxChannel = socket.channel(`ret`, { hub_id: hubId });
+  retPhxChannel
+    .join()
+    .receive("ok", async data => subscriptions.setVapidPublicKey(data.vapid_public_key))
+    .receive("error", res => {
+      subscriptions.setVapidPublicKey(null);
+      console.error(res);
+    });
+
+  const pushSubscriptionEndpoint = await subscriptions.getCurrentEndpoint();
+  const joinPayload = { profile: store.state.profile, push_subscription_endpoint: pushSubscriptionEndpoint, context };
   const hubPhxChannel = socket.channel(`hub:${hubId}`, joinPayload);
 
   hubPhxChannel
     .join()
     .receive("ok", async data => {
       hubChannel.setPhoenixChannel(hubPhxChannel);
+      subscriptions.setHubChannel(hubChannel);
+      subscriptions.setSubscribed(data.subscriptions.web_push);
+      remountUI({ initialIsSubscribed: subscriptions.isSubscribed() });
       await handleHubChannelJoined(entryManager, hubChannel, data);
     })
     .receive("error", res => {
@@ -470,8 +507,5 @@ document.addEventListener("DOMContentLoaded", async () => {
     addToPresenceLog({ name: userInfo.metas[0].profile.displayName, type, body });
   });
 
-  // Reticulum global channel
-  const retPhxChannel = socket.channel(`ret`, { hub_id: hubId });
-  retPhxChannel.join().receive("error", res => console.error(res));
   linkChannel.setSocket(socket);
 });
diff --git a/src/hub.service.js b/src/hub.service.js
new file mode 100644
index 0000000000000000000000000000000000000000..73a51a649b07c95425d98c389b0b848a56070968
--- /dev/null
+++ b/src/hub.service.js
@@ -0,0 +1,46 @@
+self.addEventListener("install", function(e) {
+  return e.waitUntil(self.skipWaiting());
+});
+
+self.addEventListener("activate", function(e) {
+  return e.waitUntil(self.clients.claim());
+});
+
+self.addEventListener("push", function(e) {
+  const payload = JSON.parse(e.data.text());
+
+  return e.waitUntil(
+    self.clients.matchAll({ type: "window" }).then(function(clientList) {
+      for (let i = 0; i < clientList.length; i++) {
+        const client = clientList[i];
+        if (client.url.indexOf(e.notification.data.hub_id) >= 0) return;
+      }
+
+      return self.registration.showNotification("Hubs by Mozilla", {
+        body: "Someone has joined " + payload.hub_name,
+        image: payload.image,
+        icon: "/favicon.ico",
+        badge: "/favicon.ico",
+        tag: payload.hub_id,
+        data: { hub_url: payload.hub_url }
+      });
+    })
+  );
+});
+
+self.addEventListener("notificationclick", function(e) {
+  e.notification.close();
+
+  e.waitUntil(
+    self.clients.matchAll({ type: "window" }).then(function(clientList) {
+      for (let i = 0; i < clientList.length; i++) {
+        const client = clientList[i];
+        if (client.url.indexOf(e.notification.data.hub_url) >= 0 && "focus" in client) return client.focus();
+      }
+
+      if (self.clients.openWindow) {
+        return self.clients.openWindow(e.notification.data.hub_url);
+      }
+    })
+  );
+});
diff --git a/src/react-components/home-root.js b/src/react-components/home-root.js
index c975d38316c00b4835e1736bb07e1e6723eaf0d4..521490271cab4cc98bbdfab4e682f23d4fef974d 100644
--- a/src/react-components/home-root.js
+++ b/src/react-components/home-root.js
@@ -231,11 +231,20 @@ class HomeRoot extends Component {
                 />
               </div>
               {this.state.environments.length > 1 && (
-                <div className={styles.joinButton}>
+                <div>
                   <WithHoverSound>
-                    <a href="/link">
-                      <FormattedMessage id="home.join_room" />
-                    </a>
+                    <div className={styles.joinButton}>
+                      <a href="/link">
+                        <FormattedMessage id="home.join_room" />
+                      </a>
+                    </div>
+                  </WithHoverSound>
+                  <WithHoverSound>
+                    <div className={styles.spokeButton}>
+                      <a href="/spoke">
+                        <FormattedMessage id="home.create_with_spoke" />
+                      </a>
+                    </div>
                   </WithHoverSound>
                 </div>
               )}
diff --git a/src/react-components/ui-root.js b/src/react-components/ui-root.js
index 32f3abc471eb25986ad46b73cae0ac2f8813c1eb..bc7ffb1fc0d973b929a16e6184e433b6726941a3 100644
--- a/src/react-components/ui-root.js
+++ b/src/react-components/ui-root.js
@@ -90,7 +90,9 @@ class UIRoot extends Component {
     isSupportAvailable: PropTypes.bool,
     presenceLogEntries: PropTypes.array,
     presences: PropTypes.object,
-    sessionId: PropTypes.string
+    sessionId: PropTypes.string,
+    subscriptions: PropTypes.object,
+    initialIsSubscribed: PropTypes.bool
   };
 
   state = {
@@ -159,6 +161,11 @@ class UIRoot extends Component {
     this.props.scene.removeEventListener("exit", this.exit);
   }
 
+  updateSubscribedState = () => {
+    const isSubscribed = this.props.subscriptions && this.props.subscriptions.isSubscribed();
+    this.setState({ isSubscribed });
+  };
+
   onSceneLoaded = () => {
     this.setState({ sceneLoaded: true });
   };
@@ -187,6 +194,13 @@ class UIRoot extends Component {
     this.props.scene.emit("penButtonPressed");
   };
 
+  onSubscribeChanged = async () => {
+    if (!this.props.subscriptions) return;
+
+    await this.props.subscriptions.toggle();
+    this.updateSubscribedState();
+  };
+
   handleStartEntry = () => {
     const promptForNameAndAvatarBeforeEntry = !this.props.store.state.activity.hasChangedName;
 
@@ -713,6 +727,8 @@ class UIRoot extends Component {
   };
 
   renderEntryStartPanel = () => {
+    const hasPush = navigator.serviceWorker && "PushManager" in window;
+
     return (
       <div className={entryStyles.entryPanel}>
         <div className={entryStyles.name}>{this.props.hubName}</div>
@@ -741,6 +757,20 @@ class UIRoot extends Component {
           </form>
         </div>
 
+        {hasPush && (
+          <div className={entryStyles.subscribe}>
+            <input
+              id="subscribe"
+              type="checkbox"
+              onChange={this.onSubscribeChanged}
+              checked={this.state.isSubscribed === undefined ? this.props.initialIsSubscribed : this.state.isSubscribed}
+            />
+            <label htmlFor="subscribe">
+              <FormattedMessage id="entry.notify_me" />
+            </label>
+          </div>
+        )}
+
         <div className={entryStyles.buttonContainer}>
           <WithHoverSound>
             <button
diff --git a/src/subscriptions.js b/src/subscriptions.js
new file mode 100644
index 0000000000000000000000000000000000000000..a5d9fc8afab5ad7b81fdeaf54fc9ecd90bab17f2
--- /dev/null
+++ b/src/subscriptions.js
@@ -0,0 +1,101 @@
+import nextTick from "./utils/next-tick.js";
+
+// Manages web push subscriptions
+//
+function urlBase64ToUint8Array(base64String) {
+  const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
+  const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
+
+  const rawData = window.atob(base64);
+  const outputArray = new Uint8Array(rawData.length);
+
+  for (let i = 0; i < rawData.length; ++i) {
+    outputArray[i] = rawData.charCodeAt(i);
+  }
+  return outputArray;
+}
+
+export default class Subscriptions {
+  constructor(hubId) {
+    this.hubId = hubId;
+  }
+
+  setHubChannel = hubChannel => {
+    this.hubChannel = hubChannel;
+  };
+
+  setRegistration = registration => {
+    this.registration = registration;
+  };
+
+  setRegistrationFailed = () => {
+    this.registration = null;
+  };
+
+  setVapidPublicKey = vapidPublicKey => {
+    this.vapidPublicKey = vapidPublicKey;
+  };
+
+  setSubscribed = isSubscribed => {
+    this._isSubscribed = isSubscribed;
+  };
+
+  isSubscribed = () => {
+    return this._isSubscribed;
+  };
+
+  getCurrentEndpoint = async () => {
+    if (!navigator.serviceWorker) return null;
+
+    // registration becomes null if failed, non null if registered
+    while (this.registration === undefined) await nextTick();
+    if (!this.registration || !this.registration.pushManager) return null;
+
+    while (this.vapidPublicKey === undefined) await nextTick();
+    if (this.vapidPublicKey === null) return null;
+
+    try {
+      const convertedVapidKey = urlBase64ToUint8Array(this.vapidPublicKey);
+
+      if (
+        (await this.registration.pushManager.permissionState({
+          userVisibleOnly: true,
+          applicationServerKey: convertedVapidKey
+        })) !== "granted"
+      )
+        return null;
+    } catch (e) {
+      return null; // Chrome can throw here complaining about userVisible if push is not right
+    }
+    const sub = await this.registration.pushManager.getSubscription();
+    if (!sub) return null;
+
+    return sub.endpoint;
+  };
+
+  toggle = async () => {
+    if (this._isSubscribed) {
+      const pushSubscription = await this.registration.pushManager.getSubscription();
+      const res = await this.hubChannel.unsubscribe(pushSubscription);
+
+      if (res && res.has_remaining_subscriptions === false) {
+        pushSubscription.unsubscribe();
+      }
+    } else {
+      let pushSubscription = await this.registration.pushManager.getSubscription();
+
+      if (!pushSubscription) {
+        const convertedVapidKey = urlBase64ToUint8Array(this.vapidPublicKey);
+
+        pushSubscription = await this.registration.pushManager.subscribe({
+          userVisibleOnly: true,
+          applicationServerKey: convertedVapidKey
+        });
+      }
+
+      this.hubChannel.subscribe(pushSubscription);
+    }
+
+    this._isSubscribed = !this._isSubscribed;
+  };
+}
diff --git a/src/systems/userinput/bindings/keyboard-mouse-user.js b/src/systems/userinput/bindings/keyboard-mouse-user.js
index 1f067d4447effc86541b93d26fb32ffaba5e471b..12e3b3e4f64b814c06427030a7b5e9be7d6aaf0a 100644
--- a/src/systems/userinput/bindings/keyboard-mouse-user.js
+++ b/src/systems/userinput/bindings/keyboard-mouse-user.js
@@ -3,6 +3,8 @@ import { sets } from "../sets";
 import { xforms } from "./xforms";
 
 const wasd_vec2 = "/var/mouse-and-keyboard/wasd_vec2";
+const keyboardCharacterAcceleration = "/var/mouse-and-keyboard/keyboardCharacterAcceleration";
+const arrows_vec2 = "/var/mouse-and-keyboard/arrows_vec2";
 const dropWithRMB = "/vars/mouse-and-keyboard/drop_with_RMB";
 const dropWithEsc = "/vars/mouse-and-keyboard/drop_with_esc";
 
@@ -10,7 +12,9 @@ const dropWithRMBorEscBindings = [
   {
     src: { value: paths.device.mouse.buttonRight },
     dest: { value: dropWithRMB },
-    xform: xforms.falling
+    xform: xforms.falling,
+    root: "rmb",
+    priority: 200
   },
   {
     src: { value: paths.device.keyboard.key("Escape") },
@@ -35,6 +39,16 @@ export const keyboardMouseUserBindings = {
       },
       xform: xforms.rising
     },
+    {
+      src: {
+        w: paths.device.keyboard.key("arrowup"),
+        a: paths.device.keyboard.key("arrowleft"),
+        s: paths.device.keyboard.key("arrowdown"),
+        d: paths.device.keyboard.key("arrowright")
+      },
+      dest: { vec2: arrows_vec2 },
+      xform: xforms.wasd_to_vec2
+    },
     {
       src: {
         w: paths.device.keyboard.key("w"),
@@ -46,9 +60,17 @@ export const keyboardMouseUserBindings = {
       xform: xforms.wasd_to_vec2
     },
     {
-      src: { value: wasd_vec2 },
+      src: {
+        first: wasd_vec2,
+        second: arrows_vec2
+      },
+      dest: { value: keyboardCharacterAcceleration },
+      xform: xforms.max_vec2
+    },
+    {
+      src: { value: keyboardCharacterAcceleration },
       dest: { value: paths.actions.characterAcceleration },
-      xform: xforms.copy
+      xform: xforms.normalize_vec2
     },
     {
       src: { value: paths.device.keyboard.key("shift") },
@@ -87,7 +109,7 @@ export const keyboardMouseUserBindings = {
     {
       src: { value: "/var/smartMouseCamDeltaX" },
       dest: { value: "/var/smartMouseCamDeltaXScaled" },
-      xform: xforms.scale(-0.06)
+      xform: xforms.scale(-0.2)
     },
     {
       src: { value: "/var/smartMouseCamDeltaY" },
@@ -116,6 +138,26 @@ export const keyboardMouseUserBindings = {
         value: paths.actions.logDebugFrame
       },
       xform: xforms.rising
+    },
+    {
+      src: {
+        value: paths.device.mouse.buttonRight
+      },
+      dest: {
+        value: paths.actions.startGazeTeleport
+      },
+      xform: xforms.rising,
+      root: "rmb",
+      priority: 100
+    },
+    {
+      src: {
+        value: paths.device.mouse.buttonRight
+      },
+      dest: {
+        value: paths.actions.stopGazeTeleport
+      },
+      xform: xforms.falling
     }
   ],
 
diff --git a/src/systems/userinput/bindings/oculus-touch-user.js b/src/systems/userinput/bindings/oculus-touch-user.js
index d386016c27497b4c3faeae1fcc3d413f3a0476fd..1d557129c05e1d7ec718dbadfb08c7c22543b84c 100644
--- a/src/systems/userinput/bindings/oculus-touch-user.js
+++ b/src/systems/userinput/bindings/oculus-touch-user.js
@@ -47,6 +47,9 @@ const leftJoyY = `${name}left/joyY`;
 const leftJoyYCursorMod = `${name}left/joyYCursorMod`;
 const oculusTouchCharacterAcceleration = `${name}characterAcceleration`;
 const keyboardCharacterAcceleration = "/var/keyboard/characterAcceleration";
+const characterAcceleration = "/var/oculus-touch/nonNormalizedCharacterAcceleration";
+const wasd_vec2 = "/var/keyboard/wasd_vec2";
+const arrows_vec2 = "/var/keyboard/arrows_vec2";
 const keyboardBoost = "/var/keyboard-oculus/boost";
 const rightBoost = "/var/right-oculus/boost";
 const leftBoost = "/var/left-oculus/boost";
@@ -224,6 +227,16 @@ export const oculusTouchUserBindings = {
       dest: { value: oculusTouchCharacterAcceleration },
       xform: xforms.compose_vec2
     },
+    {
+      src: {
+        w: paths.device.keyboard.key("arrowup"),
+        a: paths.device.keyboard.key("arrowleft"),
+        s: paths.device.keyboard.key("arrowdown"),
+        d: paths.device.keyboard.key("arrowright")
+      },
+      dest: { vec2: arrows_vec2 },
+      xform: xforms.wasd_to_vec2
+    },
     {
       src: {
         w: paths.device.keyboard.key("w"),
@@ -231,18 +244,31 @@ export const oculusTouchUserBindings = {
         s: paths.device.keyboard.key("s"),
         d: paths.device.keyboard.key("d")
       },
-      dest: { vec2: keyboardCharacterAcceleration },
+      dest: { vec2: wasd_vec2 },
       xform: xforms.wasd_to_vec2
     },
+    {
+      src: {
+        first: wasd_vec2,
+        second: arrows_vec2
+      },
+      dest: { value: keyboardCharacterAcceleration },
+      xform: xforms.max_vec2
+    },
     {
       src: {
         first: oculusTouchCharacterAcceleration,
         second: keyboardCharacterAcceleration
       },
       dest: {
-        value: paths.actions.characterAcceleration
+        value: characterAcceleration
       },
-      xform: xforms.add_vec2
+      xform: xforms.max_vec2
+    },
+    {
+      src: { value: characterAcceleration },
+      dest: { value: paths.actions.characterAcceleration },
+      xform: xforms.normalize_vec2
     },
     {
       src: { value: paths.device.keyboard.key("shift") },
diff --git a/src/systems/userinput/bindings/vive-user.js b/src/systems/userinput/bindings/vive-user.js
index 0e20233c7ff606ee29914e0d5293173404302491..39954438401c372a7070d876f8b04991b198817a 100644
--- a/src/systems/userinput/bindings/vive-user.js
+++ b/src/systems/userinput/bindings/vive-user.js
@@ -26,6 +26,7 @@ const lTriggerRisingGrab = v("right/trigger/rising/grab");
 const lGripRisingGrab = v("right/grab/rising/grab");
 const lTouchpadRising = v("left/touchpad/rising");
 const lCharacterAcceleration = v("left/characterAcceleration");
+const characterAcceleration = v("nonNormalizedCharacterAcceleration");
 const lGripFalling = v("left/grip/falling");
 const lGripRising = v("left/grip/rising");
 const leftBoost = v("left/boost");
@@ -69,6 +70,8 @@ const k = name => {
 const keyboardSnapRight = k("snap-right");
 const keyboardSnapLeft = k("snap-left");
 const keyboardCharacterAcceleration = k("characterAcceleration");
+const wasd_vec2 = k("wasd_vec2");
+const arrows_vec2 = k("arrows_vec2");
 const keyboardBoost = k("boost");
 
 const teleportLeft = [
@@ -314,6 +317,16 @@ export const viveUserBindings = {
       dest: { value: lCharacterAcceleration },
       xform: xforms.copyIfTrue
     },
+    {
+      src: {
+        w: paths.device.keyboard.key("arrowup"),
+        a: paths.device.keyboard.key("arrowleft"),
+        s: paths.device.keyboard.key("arrowdown"),
+        d: paths.device.keyboard.key("arrowright")
+      },
+      dest: { vec2: arrows_vec2 },
+      xform: xforms.wasd_to_vec2
+    },
     {
       src: {
         w: paths.device.keyboard.key("w"),
@@ -321,18 +334,31 @@ export const viveUserBindings = {
         s: paths.device.keyboard.key("s"),
         d: paths.device.keyboard.key("d")
       },
-      dest: { vec2: keyboardCharacterAcceleration },
+      dest: { vec2: wasd_vec2 },
       xform: xforms.wasd_to_vec2
     },
+    {
+      src: {
+        first: wasd_vec2,
+        second: arrows_vec2
+      },
+      dest: { value: keyboardCharacterAcceleration },
+      xform: xforms.max_vec2
+    },
     {
       src: {
         first: lCharacterAcceleration,
         second: keyboardCharacterAcceleration
       },
       dest: {
-        value: paths.actions.characterAcceleration
+        value: characterAcceleration
       },
-      xform: xforms.add_vec2
+      xform: xforms.max_vec2
+    },
+    {
+      src: { value: characterAcceleration },
+      dest: { value: paths.actions.characterAcceleration },
+      xform: xforms.normalize_vec2
     },
     {
       src: { value: paths.device.keyboard.key("shift") },
diff --git a/src/systems/userinput/bindings/xforms.js b/src/systems/userinput/bindings/xforms.js
index 444134675a842316f0f5b52f55ed8ff30083ca15..0ab4cdf5226a3840a26475cf5cd93390b7723a22 100644
--- a/src/systems/userinput/bindings/xforms.js
+++ b/src/systems/userinput/bindings/xforms.js
@@ -104,6 +104,29 @@ export const xforms = {
       frame[dest.value] = first;
     }
   },
+  max_vec2: function(frame, src, dest) {
+    const first = frame[src.first];
+    const second = frame[src.second];
+    if (first && second) {
+      frame[dest.value] =
+        first[0] * first[0] + first[1] * first[1] > second[0] * second[0] + second[1] * second[1] ? first : second;
+    } else if (second) {
+      frame[dest.value] = second;
+    } else if (first) {
+      frame[dest.value] = first;
+    }
+  },
+  normalize_vec2: function(frame, src, dest) {
+    const vec2 = frame[src.value];
+    if (vec2) {
+      if (vec2[0] === 0 && vec2[0] === 0) {
+        frame[dest.value] = vec2;
+      } else {
+        const l = Math.sqrt(vec2[0] * vec2[0] + vec2[1] * vec2[1]);
+        frame[dest.value] = [vec2[0] / l, vec2[1] / l];
+      }
+    }
+  },
   any: function(frame, src, dest) {
     for (const path in src) {
       if (frame[src[path]]) {
diff --git a/src/systems/userinput/devices/app-aware-mouse.js b/src/systems/userinput/devices/app-aware-mouse.js
index 4afd124e7006b86869309aaa3626a756b7f20292..66e006d44c56a98c07a39a498cc68581c4d0e6b9 100644
--- a/src/systems/userinput/devices/app-aware-mouse.js
+++ b/src/systems/userinput/devices/app-aware-mouse.js
@@ -30,13 +30,6 @@ export class AppAwareMouseDevice {
       this.camera = document.querySelector("#player-camera").components.camera.camera;
     }
 
-    const coords = frame[paths.device.mouse.coords];
-    const isCursorGrabbing = this.cursorController.data.cursor.components["super-hands"].state.has("grab-start");
-    if (isCursorGrabbing) {
-      frame[paths.device.smartMouse.cursorPose] = calculateCursorPose(this.camera, coords);
-      return;
-    }
-
     const buttonLeft = frame[paths.device.mouse.buttonLeft];
     if (buttonLeft && !this.prevButtonLeft) {
       const rawIntersections = [];
@@ -54,8 +47,9 @@ export class AppAwareMouseDevice {
 
     if (!this.clickedOnAnything && buttonLeft) {
       frame[paths.device.smartMouse.cameraDelta] = frame[paths.device.mouse.movementXY];
-    } else {
-      frame[paths.device.smartMouse.cursorPose] = calculateCursorPose(this.camera, coords);
     }
+
+    const coords = frame[paths.device.mouse.coords];
+    frame[paths.device.smartMouse.cursorPose] = calculateCursorPose(this.camera, coords);
   }
 }
diff --git a/src/systems/userinput/devices/mouse.js b/src/systems/userinput/devices/mouse.js
index 0ab7a3ac2aea0ce0ec44ad55fb5804348443bcae..3252fb7372a53406da0992b8b3445a9f854dd065 100644
--- a/src/systems/userinput/devices/mouse.js
+++ b/src/systems/userinput/devices/mouse.js
@@ -18,24 +18,19 @@ export class MouseDevice {
 
     const queueEvent = this.events.push.bind(this.events);
     const canvas = document.querySelector("canvas");
-    ["mousedown", "mouseup", "mousemove", "wheel"].map(x => canvas.addEventListener(x, queueEvent));
-    ["mouseout", "blur"].map(x => document.addEventListener(x, queueEvent));
+    ["mousedown", "wheel"].map(x => canvas.addEventListener(x, queueEvent));
+    ["mousemove", "mouseup"].map(x => window.addEventListener(x, queueEvent));
+    document.addEventListener("wheel", e => {
+      e.preventDefault();
+    });
   }
 
   process(event) {
     if (event.type === "wheel") {
-      this.wheel += event.deltaY / modeMod[event.deltaMode];
+      this.wheel += (event.deltaX + event.deltaY) / modeMod[event.deltaMode];
       return;
     }
-    if (event.type === "mouseout" || event.type === "blur") {
-      this.coords[0] = 0;
-      this.coords[1] = 0;
-      this.movementXY[0] = 0;
-      this.movementXY[1] = 0;
-      this.buttonLeft = false;
-      this.buttonRight = false;
-      this.wheel = 0;
-    }
+
     const left = event.button === 0;
     const right = event.button === 2;
     this.coords[0] = (event.clientX / window.innerWidth) * 2 - 1;
diff --git a/src/systems/userinput/resolve-action-sets.js b/src/systems/userinput/resolve-action-sets.js
index d1375f1b02df1c6f3d56463824a13600383fea6b..4341064a1085ba55f3584d4a3a5e00bcd5a8d6d7 100644
--- a/src/systems/userinput/resolve-action-sets.js
+++ b/src/systems/userinput/resolve-action-sets.js
@@ -136,30 +136,30 @@ export function updateActionSetsBasedOnSuperhands() {
     !cursorHoveringOnUI;
 
   const userinput = AFRAME.scenes[0].systems.userinput;
-  userinput.toggleActive(sets.leftHandHoveringOnInteractable, leftHandHoveringOnInteractable);
-  userinput.toggleActive(sets.leftHandHoveringOnPen, leftHandHoveringOnPen);
-  userinput.toggleActive(sets.leftHandHoveringOnCamera, leftHandHoveringOnCamera);
-  userinput.toggleActive(sets.leftHandHoveringOnNothing, leftHandHoveringOnNothing);
-  userinput.toggleActive(sets.leftHandHoldingPen, leftHandHoldingPen);
-  userinput.toggleActive(sets.leftHandHoldingInteractable, leftHandHoldingInteractable);
-  userinput.toggleActive(sets.leftHandHoldingCamera, leftHandHoldingCamera);
-  userinput.toggleActive(sets.leftHandTeleporting, leftHandTeleporting);
+  userinput.toggleSet(sets.leftHandHoveringOnInteractable, leftHandHoveringOnInteractable);
+  userinput.toggleSet(sets.leftHandHoveringOnPen, leftHandHoveringOnPen);
+  userinput.toggleSet(sets.leftHandHoveringOnCamera, leftHandHoveringOnCamera);
+  userinput.toggleSet(sets.leftHandHoveringOnNothing, leftHandHoveringOnNothing);
+  userinput.toggleSet(sets.leftHandHoldingPen, leftHandHoldingPen);
+  userinput.toggleSet(sets.leftHandHoldingInteractable, leftHandHoldingInteractable);
+  userinput.toggleSet(sets.leftHandHoldingCamera, leftHandHoldingCamera);
+  userinput.toggleSet(sets.leftHandTeleporting, leftHandTeleporting);
 
-  userinput.toggleActive(sets.rightHandHoveringOnInteractable, rightHandHoveringOnInteractable);
-  userinput.toggleActive(sets.rightHandHoveringOnPen, rightHandHoveringOnPen);
-  userinput.toggleActive(sets.rightHandHoveringOnNothing, rightHandHoveringOnNothing);
-  userinput.toggleActive(sets.rightHandHoveringOnCamera, rightHandHoveringOnCamera);
-  userinput.toggleActive(sets.rightHandHoldingPen, rightHandHoldingPen);
-  userinput.toggleActive(sets.rightHandHoldingInteractable, rightHandHoldingInteractable);
-  userinput.toggleActive(sets.rightHandTeleporting, rightHandTeleporting);
-  userinput.toggleActive(sets.rightHandHoldingCamera, rightHandHoldingCamera);
+  userinput.toggleSet(sets.rightHandHoveringOnInteractable, rightHandHoveringOnInteractable);
+  userinput.toggleSet(sets.rightHandHoveringOnPen, rightHandHoveringOnPen);
+  userinput.toggleSet(sets.rightHandHoveringOnNothing, rightHandHoveringOnNothing);
+  userinput.toggleSet(sets.rightHandHoveringOnCamera, rightHandHoveringOnCamera);
+  userinput.toggleSet(sets.rightHandHoldingPen, rightHandHoldingPen);
+  userinput.toggleSet(sets.rightHandHoldingInteractable, rightHandHoldingInteractable);
+  userinput.toggleSet(sets.rightHandTeleporting, rightHandTeleporting);
+  userinput.toggleSet(sets.rightHandHoldingCamera, rightHandHoldingCamera);
 
-  userinput.toggleActive(sets.cursorHoveringOnPen, cursorHoveringOnPen);
-  userinput.toggleActive(sets.cursorHoveringOnCamera, cursorHoveringOnCamera);
-  userinput.toggleActive(sets.cursorHoveringOnInteractable, cursorHoveringOnInteractable);
-  userinput.toggleActive(sets.cursorHoveringOnUI, cursorHoveringOnUI);
-  userinput.toggleActive(sets.cursorHoveringOnNothing, cursorHoveringOnNothing);
-  userinput.toggleActive(sets.cursorHoldingPen, cursorHoldingPen);
-  userinput.toggleActive(sets.cursorHoldingCamera, cursorHoldingCamera);
-  userinput.toggleActive(sets.cursorHoldingInteractable, cursorHoldingInteractable);
+  userinput.toggleSet(sets.cursorHoveringOnPen, cursorHoveringOnPen);
+  userinput.toggleSet(sets.cursorHoveringOnCamera, cursorHoveringOnCamera);
+  userinput.toggleSet(sets.cursorHoveringOnInteractable, cursorHoveringOnInteractable);
+  userinput.toggleSet(sets.cursorHoveringOnUI, cursorHoveringOnUI);
+  userinput.toggleSet(sets.cursorHoveringOnNothing, cursorHoveringOnNothing);
+  userinput.toggleSet(sets.cursorHoldingPen, cursorHoldingPen);
+  userinput.toggleSet(sets.cursorHoldingCamera, cursorHoldingCamera);
+  userinput.toggleSet(sets.cursorHoldingInteractable, cursorHoldingInteractable);
 }
diff --git a/src/systems/userinput/userinput.js b/src/systems/userinput/userinput.js
index 2ffbdca79db0e2d07d464f32ea320fc8cb31c329..8442cd73a763cf2fa8881e04267fb68d653ac0f1 100644
--- a/src/systems/userinput/userinput.js
+++ b/src/systems/userinput/userinput.js
@@ -26,29 +26,27 @@ import { updateActionSetsBasedOnSuperhands } from "./resolve-action-sets";
 import { GamepadDevice } from "./devices/gamepad";
 import { gamepadBindings } from "./bindings/generic-gamepad";
 
-const prioritizedBindings = new Map();
+const priorityMap = new Map();
 function prioritizeBindings(registeredMappings, activeSets) {
   const activeBindings = new Set();
-  prioritizedBindings.clear();
+  priorityMap.clear();
   for (const mapping of registeredMappings) {
     for (const setName in mapping) {
       if (!activeSets.has(setName) || !mapping[setName]) continue;
       for (const binding of mapping[setName]) {
         const { root, priority } = binding;
+        const prevBinding = priorityMap.get(root);
         if (!root || !priority) {
           activeBindings.add(binding);
-        } else if (!prioritizedBindings.has(root)) {
+        } else if (!prevBinding) {
           activeBindings.add(binding);
-          prioritizedBindings.set(root, binding);
-        } else {
-          const prevPriority = prioritizedBindings.get(root).priority;
-          if (priority > prevPriority) {
-            activeBindings.delete(prioritizedBindings.get(root));
-            activeBindings.add(binding);
-            prioritizedBindings.set(root, binding);
-          } else if (prevPriority === priority) {
-            console.error("equal priorities on same root", binding, prioritizedBindings.get(root));
-          }
+          priorityMap.set(root, binding);
+        } else if (priority > prevBinding.priority) {
+          activeBindings.delete(priorityMap.get(root));
+          activeBindings.add(binding);
+          priorityMap.set(root, binding);
+        } else if (prevBinding.priority === priority) {
+          console.error("equal priorities on same root", binding, priorityMap.get(root));
         }
       }
     }
@@ -57,11 +55,11 @@ function prioritizeBindings(registeredMappings, activeSets) {
 }
 
 AFRAME.registerSystem("userinput", {
-  readFrameValueAtPath(path) {
+  get(path) {
     return this.frame && this.frame[path];
   },
 
-  toggleActive(set, value) {
+  toggleSet(set, value) {
     this.pendingSetChanges.push({ set, value });
   },
 
diff --git a/src/systems/userinput/userinput.md b/src/systems/userinput/userinput.md
index d3701d5c93dd34a4be3828108d29a7a5c094c2a0..b8ceb54539665f0130863a6018b187bf0562a8aa 100644
--- a/src/systems/userinput/userinput.md
+++ b/src/systems/userinput/userinput.md
@@ -1,19 +1,4 @@
 
-# Table of Contents
-
-1.  [The userinput system](#org6030eab)
-    1.  [Overview](#org2da9acd)
-    2.  [Terms and Conventions](#org4721ce9)
-        1.  [path](#orgd62cc68)
-        2.  [action](#orgb8066a6)
-        3.  [frame](#org15eafde)
-        4.  [device](#orgea2f123)
-        5.  [binding](#org47c9c20)
-        6.  [xforms](#org876e7b0)
-        7.  [set](#orgbe4669b)
-        8.  [priority and root](#orgdd3c0c5)
-
-
 <a id="org6030eab"></a>
 
 # The userinput system
@@ -25,7 +10,7 @@ The userinput system is a module that manages mappings from device state changes
 
 ## Overview
 
-The userinput system happens to be an `aframe` `system`; its `tick` is called once a frame within the `aframe` `scene`'s `tick`. When the userinput system `tick` happens, it is responsible for creating a map called the frame. The keys of the frame are called "paths". The values stored in the frame can be any type, but are usually one of: bool, number, vec2, vec3, vec4, pose. On each tick, each connected `device` writes "raw" input values to known "device paths" within the frame. Configuration units called `bindings` are then applied to transform "raw" input values to app-specific "actions". The userinput system exposes the state of a given `action` in the current frame via `readFrameValueAtPath`. The `bindings` that are applied to transform input to "actions" must be `available`, `active`, and `prioritized`.
+The userinput system happens to be an `aframe` `system`; its `tick` is called once a frame within the `aframe` `scene`'s `tick`. When the userinput system `tick` happens, it is responsible for creating a map called the frame. The keys of the frame are called "paths". The values stored in the frame can be any type, but are usually one of: bool, number, vec2, vec3, vec4, pose. On each tick, each connected `device` writes "raw" input values to known "device paths" within the frame. Configuration units called `bindings` are then applied to transform "raw" input values to app-specific "actions". The userinput system exposes the state of a given `action` in the current frame via `get`. The `bindings` that are applied to transform input to "actions" must be `available`, `active`, and `prioritized`.
 
 1.  A `binding` is made `available` when the userinput system detects a change to the user's device configuration that matches certain criteria. A touchscreen user only has `availableBindings` related to touchscreen input. A mouse-and-keyboard user only has `availableBindings` related to mouse-and-keyboard input. An oculus/vive user has `bindings` related to mouse, keyboard, and oculus/vive controllers.
 
@@ -70,14 +55,14 @@ A path is used as a key when writing or querying the state a user input frame. P
 A path used by app code when reading a user input frame.
 
     const userinput = AFRAME.scenes[0].systems.userinput;
-    if (userinput.readFrameValueAtPath("/actions/rightHandGrab")) {
+    if (userinput.get("/actions/rightHandGrab")) {
       this.startInteraction();
     }
 
 The value in the frame can be of any type, but we have tried to keep it to simple types like bool, number, vec2, vec3, and pose.
 
     const userinput = AFRAME.scenes[0].systems.userinput;
-    const acceleration = userinput.readFrameValueAtPath("/actions/characterAcceleration");
+    const acceleration = userinput.get("/actions/characterAcceleration");
     this.updateVelocity( this.velocity, acceleration || zero );
     this.move( this.velocity );
 
diff --git a/src/utils/hub-channel.js b/src/utils/hub-channel.js
index 734cafa5fcc11c8ef84d708da4e16c32b54396aa..81714efc2fe069ce43cc8d0721c4550a2f42667d 100644
--- a/src/utils/hub-channel.js
+++ b/src/utils/hub-channel.js
@@ -91,6 +91,14 @@ export default class HubChannel {
     this.channel.push("events:profile_updated", { profile: this.store.state.profile });
   };
 
+  subscribe = subscription => {
+    this.channel.push("subscribe", { subscription });
+  };
+
+  unsubscribe = subscription => {
+    return new Promise(resolve => this.channel.push("unsubscribe", { subscription }).receive("ok", resolve));
+  };
+
   sendMessage = (body, type = "chat") => {
     if (!body) return;
     this.channel.push("message", { body, type });
diff --git a/webpack.config.js b/webpack.config.js
index e6567532eb15a79f243b7e8a5df1b9064e5eeefd..55afb6708d844a885ade6473327360ca6198c243 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -240,6 +240,12 @@ module.exports = (env, argv) => ({
         to: "hub-preview.png"
       }
     ]),
+    new CopyWebpackPlugin([
+      {
+        from: "src/hub.service.js",
+        to: "hub.service.js"
+      }
+    ]),
     // Extract required css and add a content hash.
     new ExtractTextPlugin({
       filename: "assets/stylesheets/[name]-[md5:contenthash:hex:20].css",