diff --git a/.travis.yml b/.travis.yml
index 890fcfd3ac3ca6f4d4ac9507586110c817dc3edf..57b5ad30beddf0a3e3c7f856fb89cb7fc3b11f99 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,5 +1,5 @@
 language: node_js
-node_js: node
+node_js: "9"
 cache: yarn
 before_install:
   - curl -o- -L https://yarnpkg.com/install.sh | bash -s -- --version 1.5.1
diff --git a/package.json b/package.json
index b70f19a6db7b579488a153b78efc7c57ee6cc7fb..bf3cae400bf95c01a23c6200b49007c4a2b72344 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,7 @@
     "@fortawesome/react-fontawesome": "^0.0.18",
     "aframe-billboard-component": "^1.0.0",
     "aframe-extras": "https://github.com/MozillaReality/aframe-extras#feature/precompute-nav-mesh",
-    "aframe-input-mapping-component": "https://github.com/johnshaughnessy/aframe-input-mapping-component#feature/map-to-array",
+    "aframe-input-mapping-component": "https://github.com/mozillareality/aframe-input-mapping-component#hubs/master",
     "aframe-physics-extras": "https://github.com/infinitelee/aframe-physics-extras#fix/physics-collider-crash",
     "aframe-physics-system": "https://github.com/infinitelee/aframe-physics-system#feature/shape-component",
     "aframe-rounded": "^1.0.3",
diff --git a/scripts/build_local_reticulum.sh b/scripts/build_local_reticulum.sh
index 118a801e24fba4fdbed51c5b23f793db46cc498f..f883958f8f18e028a32916f50c6af38002938893 100755
--- a/scripts/build_local_reticulum.sh
+++ b/scripts/build_local_reticulum.sh
@@ -4,4 +4,4 @@ if [ ! -e ../reticulum ]; then
   echo "This script assumes reticulum is checked out in a sibling to this folder."
 fi
 
-rm -rf ../reticulum/priv/static ; BASE_ASSETS_PATH=http://localhost:4000/ yarn build -- --output-path ../reticulum/priv/static 
+rm -rf ../reticulum/priv/static ; GENERATE_SMOKE_TESTS=true BASE_ASSETS_PATH=http://localhost:4000/ yarn build -- --output-path ../reticulum/priv/static 
diff --git a/scripts/default.env b/scripts/default.env
index b5dbe2c7c9ec3b22eba8d36f07c2fbd700bf0cb0..3d6556c0fa826b118d94dc8bc88a9f2555c5a520 100644
--- a/scripts/default.env
+++ b/scripts/default.env
@@ -1,6 +1,7 @@
 # This origin trial token is used to enable WebVR and Gamepad Extensions on Chrome 62+
 # You can find more information about getting your own origin trial token here: https://github.com/GoogleChrome/OriginTrials/blob/gh-pages/developer-guide.md
-ORIGIN_TRIAL_TOKEN="ArEZ0vY0uMo3pj+oY8Up4u4Hy8QolJwKxG4/2WRhSPnTZRrviiGhzP6/y72nBdsIhdEyoundxqg//KLbs2vGnQoAAABkeyJvcmlnaW4iOiJodHRwczovL3JldGljdWx1bS5pbzo0NDMiLCJmZWF0dXJlIjoiV2ViVlIxLjFNNjIiLCJleHBpcnkiOjE1MjYzNDg2MjEsImlzU3ViZG9tYWluIjp0cnVlfQ=="
+ORIGIN_TRIAL_TOKEN="AgN/JtqSF6qpD3OZk8KgM5/UYqUUrwc166cOQSRCqvU+TIpHWdiwBUWH5V1K/jJkdtBrO4Q5I0XSGm16uB/Y4QQAAABVeyJvcmlnaW4iOiJodHRwczovL2h1YnMubW96aWxsYS5jb206NDQzIiwiZmVhdHVyZSI6IldlYlZSMS4xTTYyIiwiZXhwaXJ5IjoxNTI4MjQ1ODI1fQ=="
 ORIGIN_TRIAL_EXPIRES="2018-05-15"
 JANUS_SERVER="wss://prod-janus.reticulum.io"
 DEV_RETICULUM_SERVER="dev.reticulum.io"
+ASSET_BUNDLE_SERVER="https://asset-bundles-prod.reticulum.io"
diff --git a/src/assets/environments/environments.js b/src/assets/environments/environments.js
new file mode 100644
index 0000000000000000000000000000000000000000..7c20ce61da763de912493c4e8d35385d09347b5a
--- /dev/null
+++ b/src/assets/environments/environments.js
@@ -0,0 +1,7 @@
+export const ENVIRONMENT_URLS = [
+  process.env.ASSET_BUNDLE_SERVER + "/rooms/meetingroom/MeetingRoom.bundle.json",
+  process.env.ASSET_BUNDLE_SERVER + "/rooms/atrium/Atrium.bundle.json",
+  process.env.ASSET_BUNDLE_SERVER + "/rooms/MedievalFantasyBook/MedievalFantasyBook.bundle.json"
+];
+
+export const DEFAULT_ENVIRONMENT_URL = ENVIRONMENT_URLS[0];
diff --git a/src/assets/images/hub-preview.png b/src/assets/images/hub-preview.png
new file mode 100755
index 0000000000000000000000000000000000000000..5a976607e2539031d67dc17e727ecff02740c3ad
Binary files /dev/null and b/src/assets/images/hub-preview.png differ
diff --git a/src/assets/sfx/quack.mp3 b/src/assets/sfx/quack.mp3
new file mode 100644
index 0000000000000000000000000000000000000000..5c9d5c87de7cc144af2afe5175151cf0c89ecff1
Binary files /dev/null and b/src/assets/sfx/quack.mp3 differ
diff --git a/src/assets/sfx/specialquack.mp3 b/src/assets/sfx/specialquack.mp3
new file mode 100644
index 0000000000000000000000000000000000000000..ac461e6c916fc2cb86230d66661c8316bd415fbb
Binary files /dev/null and b/src/assets/sfx/specialquack.mp3 differ
diff --git a/src/assets/stylesheets/footer.scss b/src/assets/stylesheets/footer.scss
index f0a48bc6d15b8c1efab6c902f7e7024985f12f5f..5f782fd03c56c8be7f7115b1a99519c22ad26804 100644
--- a/src/assets/stylesheets/footer.scss
+++ b/src/assets/stylesheets/footer.scss
@@ -10,7 +10,7 @@
   // Position above virtual gamepad controls on mobile
   z-index: 1;
 
-  @media (min-width: 769px) and (min-height: 401px) {
+  @media (min-width: 769px) and (min-height: 421px) {
     pointer-events: auto;
   }
 }
@@ -35,25 +35,25 @@
   background-color: transparent;
   border-bottom: 1px solid rgba(32, 32, 32, 0.65);
 
-  @media (min-width: 769px) , (max-height: 401px) {
+  @media (min-width: 769px) , (max-height: 421px) {
     display: none;
   }
 }
 :local(.header) {
   background-color: rgba(0, 0, 0, 0.65);
 
-  @media (max-width: 768px) , (max-height: 400px) {
+  @media (max-width: 768px) , (max-height: 420px) {
     background-color: transparent;
   }
 
   :local(.hub-info) {
-    @media (max-width: 768px) , (max-height: 400px) {
+    @media (max-width: 768px) , (max-height: 420px) {
       display: none;
     }
   }
 
   :local(.hub-stats) {
-    @media (max-width: 768px) , (max-height: 400px) {
+    @media (max-width: 768px) , (max-height: 420px) {
       display: none;
     }
   }
@@ -64,7 +64,7 @@
   margin: 16px 24px;
   display: flex;
   align-items: center;
-  @media (max-width: 768px) , (max-height: 400px) {
+  @media (max-width: 768px) , (max-height: 420px) {
     margin: 16px 8px;
     margin-left: 24px;
     font-size: 0.9em;
@@ -76,10 +76,10 @@
   display: flex;
   align-items: center;
   justify-content: flex-end;
-  @media (min-width: 769px) and (min-height: 401px) {
+  @media (min-width: 769px) and (min-height: 421px) {
     flex: 1;
   }
-  @media (max-width: 768px) , (max-height: 400px) {
+  @media (max-width: 768px) , (max-height: 420px) {
     margin: 16px 8px;
   }
   :local(.hub-participant-count) {
@@ -109,13 +109,13 @@
   }
 
   :local(.menu-button__narrow-close-icon) {
-    @media (max-width: 768px) , (max-height: 400px) {
+    @media (max-width: 768px) , (max-height: 420px) {
       display: none;
     }
   }
 
   :local(.menu-button__wide-close-icon) {
-    @media (min-width: 769px) and (min-height: 401px) {
+    @media (min-width: 769px) and (min-height: 421px) {
       display: none;
     }
   }
diff --git a/src/components/controls-shape-offset.js b/src/components/controls-shape-offset.js
new file mode 100644
index 0000000000000000000000000000000000000000..7ce8764332f0e8b7b6116add6a8e8178a84121f6
--- /dev/null
+++ b/src/components/controls-shape-offset.js
@@ -0,0 +1,45 @@
+import { CONTROLLER_OFFSETS } from "./hand-controls2.js";
+
+AFRAME.registerComponent("controls-shape-offset", {
+  schema: {
+    additionalOffset: { default: { x: 0, y: -0.03, z: -0.04 } }
+  },
+  init: function() {
+    this.controller = null;
+    this.shapeAdded = false;
+
+    this._handleControllerConnected = this._handleControllerConnected.bind(this);
+    this.el.addEventListener("controllerconnected", this._handleControllerConnected);
+  },
+
+  remove: function() {
+    this.el.removeEventListener("controllerconnected", this._handleControllerConnected);
+  },
+
+  tick: function() {
+    if (!this.shapeAdded && this.controller) {
+      this.shapeAdded = true;
+      const hasOffset = CONTROLLER_OFFSETS.hasOwnProperty(this.controller);
+      const offset = hasOffset ? CONTROLLER_OFFSETS[this.controller] : CONTROLLER_OFFSETS.default;
+      const position = new THREE.Vector3();
+      const quaternion = new THREE.Quaternion();
+      const scale = new THREE.Vector3();
+      offset.decompose(position, quaternion, scale);
+      position.add(this.data.additionalOffset);
+      quaternion.conjugate();
+
+      const shape = {
+        shape: "sphere",
+        radius: "0.02",
+        orientation: quaternion,
+        offset: position
+      };
+
+      this.el.setAttribute("shape", shape);
+    }
+  },
+
+  _handleControllerConnected: function(e) {
+    this.controller = e.detail.name;
+  }
+});
diff --git a/src/components/css-class.js b/src/components/css-class.js
new file mode 100644
index 0000000000000000000000000000000000000000..1528ed4d450aea61f63b7426524229ed63609044
--- /dev/null
+++ b/src/components/css-class.js
@@ -0,0 +1,17 @@
+AFRAME.registerComponent("css-class", {
+  schema: {
+    type: "string"
+  },
+  init() {
+    this.el.classList.add(this.data);
+  },
+  update(oldData) {
+    if (this.data !== oldData) {
+      this.el.classList.remove(oldData);
+      this.el.classList.add(this.data);
+    }
+  },
+  remove() {
+    this.el.classList.remove(this.data);
+  }
+});
diff --git a/src/components/cursor-controller.js b/src/components/cursor-controller.js
index 572b17d91f606912b612fb3e1b8e440f33be46a0..2acb029db487a392802bd14f6457d2a4d5be7758 100644
--- a/src/components/cursor-controller.js
+++ b/src/components/cursor-controller.js
@@ -78,16 +78,13 @@ AFRAME.registerComponent("cursor-controller", {
   },
 
   play: function() {
-    if (!this.inVR && this.isMobile && !this.hasPointingDevice) {
-      document.addEventListener("touchstart", this._handleTouchStart);
-      document.addEventListener("touchmove", this._handleTouchMove);
-      document.addEventListener("touchend", this._handleTouchEnd);
-    } else {
-      document.addEventListener("mousedown", this._handleMouseDown);
-      document.addEventListener("mousemove", this._handleMouseMove);
-      document.addEventListener("mouseup", this._handleMouseUp);
-      document.addEventListener("wheel", this._handleWheel);
-    }
+    document.addEventListener("touchstart", this._handleTouchStart);
+    document.addEventListener("touchmove", this._handleTouchMove);
+    document.addEventListener("touchend", this._handleTouchEnd);
+    document.addEventListener("mousedown", this._handleMouseDown);
+    document.addEventListener("mousemove", this._handleMouseMove);
+    document.addEventListener("mouseup", this._handleMouseUp);
+    document.addEventListener("wheel", this._handleWheel);
 
     window.addEventListener("enter-vr", this._handleEnterVR);
     window.addEventListener("exit-vr", this._handleExitVR);
@@ -258,6 +255,8 @@ AFRAME.registerComponent("cursor-controller", {
   },
 
   _handleTouchStart: function(e) {
+    if (!this.isMobile || this.hasPointingDevice) return;
+
     const touch = e.touches[0];
     if (touch.clientY / window.innerHeight >= 0.8) return true;
     this.mousePos.set(touch.clientX / window.innerWidth * 2 - 1, -(touch.clientY / window.innerHeight) * 2 + 1);
@@ -303,6 +302,8 @@ AFRAME.registerComponent("cursor-controller", {
   },
 
   _handleTouchMove: function(e) {
+    if (!this.isMobile || this.hasPointingDevice) return;
+
     for (let i = 0; i < e.touches.length; i++) {
       const touch = e.touches[i];
       if (touch.clientY / window.innerHeight >= 0.8) return true;
@@ -312,6 +313,8 @@ AFRAME.registerComponent("cursor-controller", {
   },
 
   _handleTouchEnd: function(e) {
+    if (!this.isMobile || this.hasPointingDevice) return;
+
     for (let i = 0; i < e.changedTouches.length; i++) {
       const touch = e.changedTouches[i];
       const thisTouchDidNotDriveMousePos =
@@ -326,6 +329,8 @@ AFRAME.registerComponent("cursor-controller", {
   },
 
   _handleMouseDown: function() {
+    if (this.isMobile && !this.inVR && !this.hasPointingDevice) return;
+
     if (this._isTargetOfType(TARGET_TYPE_INTERACTABLE_OR_UI)) {
       this._setLookControlsEnabled(false);
       this.data.cursor.emit("cursor-grab", {});
@@ -335,10 +340,14 @@ AFRAME.registerComponent("cursor-controller", {
   },
 
   _handleMouseMove: function(e) {
+    if (this.isMobile && !this.inVR && !this.hasPointingDevice) return;
+
     this.mousePos.set(e.clientX / window.innerWidth * 2 - 1, -(e.clientY / window.innerHeight) * 2 + 1);
   },
 
   _handleMouseUp: function() {
+    if (this.isMobile && !this.inVR && !this.hasPointingDevice) return;
+
     this._setLookControlsEnabled(true);
     this.data.cursor.emit("cursor-release", {});
     this._endTeleport();
diff --git a/src/components/duck.js b/src/components/duck.js
new file mode 100644
index 0000000000000000000000000000000000000000..59172942074173c393c7d86e9fd9e121737ae128
--- /dev/null
+++ b/src/components/duck.js
@@ -0,0 +1,41 @@
+/* global CANNON */
+AFRAME.registerComponent("duck", {
+  schema: {
+    initialForce: { default: 0 },
+    maxForce: { default: 6.5 },
+    maxScale: { default: 5 }
+  },
+
+  init: function() {
+    this.physicsSystem = this.el.sceneEl.systems.physics;
+    this.hasBody = false;
+    this.position = new CANNON.Vec3();
+    this.force = new CANNON.Vec3(0, this.data.initialForce, 0);
+    this.initialScale = this.el.object3D.scale.x;
+    this.maxScale = this.data.maxScale * this.initialScale;
+  },
+
+  play: function() {
+    this.physicsSystem.addComponent(this);
+  },
+
+  pause: function() {
+    this.physicsSystem.removeComponent(this);
+  },
+
+  beforeStep: function() {
+    if (this.el.body && NAF.utils.isMine(this.el)) {
+      const currentScale = this.el.object3D.scale.x;
+      const ratio = Math.min(1, (currentScale - this.initialScale) / (this.maxScale - this.initialScale));
+      const force = ratio * this.data.maxForce;
+      if (force > 0) {
+        const angle = Math.random() * Math.PI * 2;
+        const x = Math.cos(angle);
+        const z = Math.sin(angle);
+        this.force.set(x, force, z);
+        this.position.set(x * 0.01, 0, z * 0.01);
+        this.el.body.applyForce(this.force, this.position);
+      }
+    }
+  }
+});
diff --git a/src/components/hand-controls2.js b/src/components/hand-controls2.js
index af86cd5da272ea5aee5c60e75578da84a13aaef5..a5f212453d00a4dd6d7fdeb001dd36dc545c5057 100644
--- a/src/components/hand-controls2.js
+++ b/src/components/hand-controls2.js
@@ -10,7 +10,7 @@ const POSES = {
   mrpDown: "mrpDown"
 };
 
-const CONTROLLER_OFFSETS = {
+export const CONTROLLER_OFFSETS = {
   default: new THREE.Matrix4(),
   "oculus-touch-controls": new THREE.Matrix4().makeTranslation(0, -0.015, 0.04),
   "vive-controls": new THREE.Matrix4().compose(
@@ -18,6 +18,11 @@ const CONTROLLER_OFFSETS = {
     new THREE.Quaternion().setFromEuler(new THREE.Euler(-40 * THREE.Math.DEG2RAD, 0, 0)),
     new THREE.Vector3(1, 1, 1)
   ),
+  "windows-motion-controls": new THREE.Matrix4().compose(
+    new THREE.Vector3(0, -0.017, 0.13),
+    new THREE.Quaternion().setFromEuler(new THREE.Euler(-40 * THREE.Math.DEG2RAD, 0, 0)),
+    new THREE.Vector3(1, 1, 1)
+  ),
   "daydream-controls": new THREE.Matrix4().makeTranslation(0, 0, -0.04),
   "gearvr-controls": new THREE.Matrix4()
 };
diff --git a/src/components/haptic-feedback.js b/src/components/haptic-feedback.js
index 35a79cd9555e2864e5c496f671fcb9ddd8831345..9a0a2337de1ef895bdbd3bb699259f3063187cf2 100644
--- a/src/components/haptic-feedback.js
+++ b/src/components/haptic-feedback.js
@@ -10,7 +10,7 @@ AFRAME.registerComponent("haptic-feedback", {
   },
 
   init: function() {
-    this.pulse = this.pulse.bind(this);
+    this.handlePulse = this.handlePulse.bind(this);
     this.getActuator = this.getActuator.bind(this);
     this.getActuator().then(actuator => {
       this.actuator = actuator;
@@ -37,21 +37,27 @@ AFRAME.registerComponent("haptic-feedback", {
   },
 
   play: function() {
-    this.el.addEventListener(this.data.hapticEventName, this.pulse);
+    this.el.addEventListener(this.data.hapticEventName, this.handlePulse);
   },
   pause: function() {
-    this.el.removeEventListener(this.data.hapticEventName, this.pulse);
+    this.el.removeEventListener(this.data.hapticEventName, this.handlePulse);
   },
 
-  pulse: function(event) {
+  handlePulse: function(event) {
     const { intensity } = event.detail;
-    if (!strengthForIntensity[intensity]) {
+
+    if (strengthForIntensity[intensity]) {
+      this.pulse(strengthForIntensity[intensity]);
+    } else if (Number(intensity) === intensity) {
+      this.pulse(intensity);
+    } else {
       console.warn(`Invalid intensity : ${intensity}`);
-      return;
     }
+  },
 
+  pulse: function(intensity) {
     if (this.actuator) {
-      this.actuator.pulse(strengthForIntensity[intensity], 15);
+      this.actuator.pulse(intensity, 15);
     }
   }
 });
diff --git a/src/components/icon-button.js b/src/components/icon-button.js
index eab80803f4d85ad425a6d5a0c71fc8f6f9ddc333..88f5e7303822ff103d7e67479ab99b3d7bddba4f 100644
--- a/src/components/icon-button.js
+++ b/src/components/icon-button.js
@@ -5,7 +5,10 @@ AFRAME.registerComponent("icon-button", {
     activeImage: { type: "string" },
     activeHoverImage: { type: "string" },
     active: { type: "boolean" },
-    haptic: { type: "selector" }
+    haptic: { type: "selector" },
+    tooltip: { type: "selector" },
+    tooltipText: { type: "string" },
+    activeTooltipText: { type: "string" }
   },
 
   init() {
@@ -53,5 +56,12 @@ AFRAME.registerComponent("icon-button", {
     const image = active ? (hovering ? "activeHoverImage" : "activeImage") : hovering ? "hoverImage" : "image";
 
     this.el.setAttribute("src", this.data[image]);
+
+    if (this.data.tooltip) {
+      this.data.tooltip.setAttribute("visible", this.hovering);
+      this.data.tooltip
+        .querySelector("[text]")
+        .setAttribute("text", "value", this.data.active ? this.data.activeTooltipText : this.data.tooltipText);
+    }
   }
 });
diff --git a/src/components/quack.js b/src/components/quack.js
new file mode 100644
index 0000000000000000000000000000000000000000..e97b6f8f1bf5cfccd2efab3dcc4f486d09c2330d
--- /dev/null
+++ b/src/components/quack.js
@@ -0,0 +1,27 @@
+AFRAME.registerComponent("quack", {
+  schema: {
+    quackPercentage: { default: 1 },
+    specialQuackPercentage: { default: 0.01 }
+  },
+
+  init: function() {
+    this._handleGrabStart = this._handleGrabStart.bind(this);
+  },
+
+  play: function() {
+    this.el.addEventListener("grab-start", this._handleGrabStart);
+  },
+
+  pause: function() {
+    this.el.removeEventListener("grab-start", this._handleGrabStart);
+  },
+
+  _handleGrabStart: function() {
+    const rand = Math.random();
+    if (rand < this.data.specialQuackPercentage) {
+      this.el.emit("specialquack");
+    } else if (rand < this.data.quackPercentage) {
+      this.el.emit("quack");
+    }
+  }
+});
diff --git a/src/components/scene-shadow.js b/src/components/scene-shadow.js
new file mode 100644
index 0000000000000000000000000000000000000000..72d77cf36b36c80279b0683531f4682f1f5b7a47
--- /dev/null
+++ b/src/components/scene-shadow.js
@@ -0,0 +1,30 @@
+// For use in environment gltf bundles to set scene shadow properties.
+AFRAME.registerComponent("scene-shadow", {
+  schema: {
+    autoUpdate: {
+      type: "boolean",
+      default: true
+    },
+    type: {
+      type: "string",
+      default: "pcf"
+    },
+    renderReverseSided: {
+      type: "boolean",
+      default: true
+    },
+    renderSingleSided: {
+      type: "boolean",
+      default: true
+    }
+  },
+  init() {
+    this.originalShadowProperties = this.el.sceneEl.getAttribute("shadow");
+  },
+  update() {
+    this.el.sceneEl.setAttribute("shadow", this.data);
+  },
+  remove() {
+    this.el.sceneEl.setAttribute("shadow", this.originalShadowProperties);
+  }
+});
diff --git a/src/components/spawn-controller.js b/src/components/spawn-controller.js
index eab8314c8664965cbdd2b19acf99fa74609052c5..1daf01b756e4308f1f9d50ceffd362aeb4b8e399 100644
--- a/src/components/spawn-controller.js
+++ b/src/components/spawn-controller.js
@@ -1,31 +1,26 @@
 AFRAME.registerComponent("spawn-controller", {
   schema: {
-    radius: { type: "number", default: 1 }
+    target: { type: "selector" },
+    loadedEvent: { type: "string" }
   },
-
   init() {
-    const el = this.el;
-    const center = el.getAttribute("position");
+    this.onLoad = this.onLoad.bind(this);
+    this.data.target.addEventListener(this.data.loadedEvent, this.onLoad);
+  },
+  onLoad() {
+    const spawnPoints = document.querySelectorAll("[spawn-point]");
 
-    const angleRad = Math.random() * 2 * Math.PI;
-    const circlePoint = this.getPointOnCircle(this.data.radius, angleRad);
-    const worldPoint = {
-      x: circlePoint.x + center.x,
-      y: center.y,
-      z: circlePoint.z + center.z
-    };
-    el.setAttribute("position", worldPoint);
+    if (spawnPoints.length === 0) {
+      // Keep default position
+      return;
+    }
 
-    const angleDeg = angleRad * THREE.Math.RAD2DEG;
-    const angleToCenter = -1 * angleDeg + 90;
-    el.setAttribute("rotation", { x: 0, y: angleToCenter, z: 0 });
+    const spawnPointIndex = Math.round((spawnPoints.length - 1) * Math.random());
+    const spawnPoint = spawnPoints[spawnPointIndex];
 
-    el.object3D.updateMatrix();
-  },
-
-  getPointOnCircle(radius, angleRad) {
-    const x = Math.cos(angleRad) * radius;
-    const z = Math.sin(angleRad) * radius;
-    return { x: x, z: z };
+    spawnPoint.object3D.getWorldPosition(this.el.object3D.position);
+    this.el.object3D.rotation.copy(spawnPoint.object3D.rotation);
   }
 });
+
+AFRAME.registerComponent("spawn-point", {});
diff --git a/src/components/super-networked-interactable.js b/src/components/super-networked-interactable.js
index a5baed53dd2f3f5404a81a26150904353434e9e9..0d9fc26dea6304e7ea66f165f76a62bd7044b2d3 100644
--- a/src/components/super-networked-interactable.js
+++ b/src/components/super-networked-interactable.js
@@ -1,10 +1,12 @@
 AFRAME.registerComponent("super-networked-interactable", {
   schema: {
     mass: { default: 1 },
+    hapticsMassVelocityFactor: { default: 0.1 },
     counter: { type: "selector" }
   },
 
   init: function() {
+    this.system = this.el.sceneEl.systems.physics;
     this.counter = this.data.counter.components["networked-counter"];
     this.hand = null;
 
@@ -21,12 +23,23 @@ AFRAME.registerComponent("super-networked-interactable", {
     this.ownershipLostListener = this._onOwnershipLost.bind(this);
     this.el.addEventListener("grab-start", this.grabStartListener);
     this.el.addEventListener("ownership-lost", this.ownershipLostListener);
+    this.system.addComponent(this);
   },
 
   remove: function() {
     this.counter.deregister(this.el);
     this.el.removeEventListener("grab-start", this.grabStartListener);
     this.el.removeEventListener("ownership-lost", this.ownershipLostListener);
+    this.system.removeComponent(this);
+  },
+
+  afterStep: function() {
+    if (this.el.is("grabbed") && this.hand && this.hand.components.hasOwnProperty("haptic-feedback")) {
+      const hapticFeedback = this.hand.components["haptic-feedback"];
+      let velocity = this.el.body.velocity.lengthSquared() * this.el.body.mass * this.data.hapticsMassVelocityFactor;
+      velocity = Math.min(1, velocity);
+      hapticFeedback.pulse(velocity);
+    }
   },
 
   _onGrabStart: function(e) {
diff --git a/src/components/super-spawner.js b/src/components/super-spawner.js
index 9d36c6583f01909e0db08f93450c78cd78c3c3aa..41e2d7aeabbe77f2dc0ba9b3821170fab9724db5 100644
--- a/src/components/super-spawner.js
+++ b/src/components/super-spawner.js
@@ -3,11 +3,14 @@ AFRAME.registerComponent("super-spawner", {
     template: { default: "" },
     useCustomSpawnPosition: { default: false },
     spawnPosition: { type: "vec3" },
-    events: { default: ["cursor-grab", "action_grab"] }
+    events: { default: ["cursor-grab", "action_grab"] },
+    spawnCooldown: { default: 1 }
   },
 
   init: function() {
     this.entities = new Map();
+    this.timeout = null;
+    this.defaultScale = this.el.getAttribute("scale").clone();
   },
 
   play: function() {
@@ -17,19 +20,27 @@ AFRAME.registerComponent("super-spawner", {
 
   pause: function() {
     this.el.removeEventListener("grab-start", this.handleGrabStart);
+
+    clearTimeout(this.timeout);
+    this.timeout = null;
+    this.el.setAttribute("visible", true);
+    this.el.setAttribute("scale", this.defaultScale);
   },
 
   remove: function() {
     for (const entity of this.entities.keys()) {
       const data = this.entities.get(entity);
       entity.removeEventListener("componentinitialized", data.componentinInitializedListener);
-      entity.removeEventListener("bodyloaded", data.bodyLoadedListener);
+      entity.removeEventListener("body-loaded", data.bodyLoadedListener);
     }
 
     this.entities.clear();
   },
 
   _handleGrabStart: function(e) {
+    if (this.timeout) {
+      return;
+    }
     const hand = e.detail.hand;
     const entity = document.createElement("a-entity");
 
@@ -51,6 +62,16 @@ AFRAME.registerComponent("super-spawner", {
     const pos = this.data.useCustomSpawnPosition ? this.data.spawnPosition : this.el.getAttribute("position");
     entity.setAttribute("position", pos);
     this.el.sceneEl.appendChild(entity);
+
+    if (this.data.spawnCooldown > 0) {
+      this.el.setAttribute("visible", false);
+      this.el.setAttribute("scale", { x: 0.0001, y: 0.0001, z: 0.0001 });
+      this.timeout = setTimeout(() => {
+        this.el.setAttribute("visible", true);
+        this.el.setAttribute("scale", this.defaultScale);
+        this.timeout = null;
+      }, this.data.spawnCooldown * 1000);
+    }
   },
 
   _handleComponentInitialzed: function(entity, e) {
diff --git a/src/components/water.js b/src/components/water.js
index b7f176131ea97f6f47fbbb31b27c59e80350b78b..cfa33e4b166eea99f5af30d5c5fa36d0075c7378 100644
--- a/src/components/water.js
+++ b/src/components/water.js
@@ -148,10 +148,14 @@ AFRAME.registerComponent("water", {
     distance: { type: "number", default: 1 },
     speed: { type: "number", default: 0.1 },
     forceMobile: { type: "boolean", default: false },
-    normalMap: { type: "asset" }
+    normalMap: { type: "asset", default: "#water-normal-map" }
   },
   init() {
-    const waterGeometry = new THREE.PlaneBufferGeometry(800, 800);
+    const waterMesh = this.el.getObject3D("mesh");
+    const waterGeometry = waterMesh.geometry;
+
+    // Render THREE.Water shader instead of THREE.Mesh
+    waterMesh.visible = false;
 
     const waterNormals = new THREE.Texture(this.data.normalMap);
     waterNormals.wrapS = waterNormals.wrapT = THREE.RepeatWrapping;
@@ -223,5 +227,7 @@ AFRAME.registerComponent("water", {
 
   remove() {
     this.el.removeObject3D("water");
+    const waterMesh = this.el.getObject3D("mesh");
+    waterMesh.visible = true;
   }
 });
diff --git a/src/gltf-component-mappings.js b/src/gltf-component-mappings.js
index e2177fb25a1025ad56e9b135626b3abca64b1d97..99b9b0bf6c2dae246cfd33bc654bd21260d98eef 100644
--- a/src/gltf-component-mappings.js
+++ b/src/gltf-component-mappings.js
@@ -1,10 +1,27 @@
 import "./components/gltf-model-plus";
 import { resolveURL } from "./utils/resolveURL";
 
+AFRAME.GLTFModelPlus.registerComponent("quack", "quack");
+AFRAME.GLTFModelPlus.registerComponent("sound", "sound");
+AFRAME.GLTFModelPlus.registerComponent("collision-filter", "collision-filter");
+AFRAME.GLTFModelPlus.registerComponent("css-class", "css-class");
+AFRAME.GLTFModelPlus.registerComponent("scene-shadow", "scene-shadow");
+AFRAME.GLTFModelPlus.registerComponent("super-spawner", "super-spawner");
+AFRAME.GLTFModelPlus.registerComponent("gltf-model-plus", "gltf-model-plus");
+AFRAME.GLTFModelPlus.registerComponent("body", "body");
+AFRAME.GLTFModelPlus.registerComponent("hide-when-quality", "hide-when-quality");
+AFRAME.GLTFModelPlus.registerComponent("light", "light");
+AFRAME.GLTFModelPlus.registerComponent("skybox", "skybox");
+AFRAME.GLTFModelPlus.registerComponent("layers", "layers");
+AFRAME.GLTFModelPlus.registerComponent("shadow", "shadow");
+AFRAME.GLTFModelPlus.registerComponent("xr", "xr");
+AFRAME.GLTFModelPlus.registerComponent("water", "water");
 AFRAME.GLTFModelPlus.registerComponent("scale-audio-feedback", "scale-audio-feedback");
+AFRAME.GLTFModelPlus.registerComponent("animation-mixer", "animation-mixer");
 AFRAME.GLTFModelPlus.registerComponent("loop-animation", "loop-animation");
 AFRAME.GLTFModelPlus.registerComponent("shape", "shape");
 AFRAME.GLTFModelPlus.registerComponent("visible", "visible");
+AFRAME.GLTFModelPlus.registerComponent("spawn-point", "spawn-point");
 AFRAME.GLTFModelPlus.registerComponent("nav-mesh", "nav-mesh", (el, componentName, componentData, gltfPath) => {
   if (componentData.src) {
     componentData.src = resolveURL(componentData.src, gltfPath);
diff --git a/src/hub.html b/src/hub.html
index 581bc3ffe008dc53cb4be3c414c119935af629e6..af1bb3ef2bf5ff1ccd6839793ac70b7395af89f0 100644
--- a/src/hub.html
+++ b/src/hub.html
@@ -2,6 +2,8 @@
 <html>
 
 <head>
+    <!-- DO NOT REMOVE/EDIT THIS COMMENT - HUB_META_TAGS -->
+
     <meta charset="utf-8">
     <meta name="viewport" content="width=device-width, initial-scale=1">
     <meta http-equiv="origin-trial" data-feature="WebVR (For Chrome M62+)" data-expires="<%= ORIGIN_TRIAL_EXPIRES %>" content="<%= ORIGIN_TRIAL_TOKEN %>">
@@ -28,7 +30,7 @@
 
     <a-scene
         networked-scene="adapter: janus; audio: true; debug: true; connectOnLoad: false;"
-        physics
+        physics="gravity: -6;"
         mute-mic="eventSrc: a-scene; toggleEvents: action_mute"
         freeze-controller="toggleEvent: action_freeze"
         personal-space-bubble="debug: false;"
@@ -63,6 +65,9 @@
             <a-asset-item id="watch-model" response-type="arraybuffer" src="./assets/hud/watch.glb"></a-asset-item>
             <a-asset-item id="interactable-duck" response-type="arraybuffer" src="./assets/interactables/duck/DuckyMesh.glb"></a-asset-item>
 
+            <a-asset-item id="quack" src="./assets/sfx/quack.mp3" response-type="arraybuffer" preload="auto"></a-asset-item>
+            <a-asset-item id="specialquack" src="./assets/sfx/specialquack.mp3" response-type="arraybuffer" preload="auto"></a-asset-item>
+
             <img id="water-normal-map" src="./assets/waternormals.jpg">
 
             <!-- Templates -->
@@ -136,6 +141,13 @@
                                 personal-space-invader="radius: 0.15; useMaterial: true;"
                                 bone-visibility
                             >
+                            <a-cylinder
+                                static-body
+                                radius="0.13"
+                                height="0.2"
+                                position="0 0.07 0.05"
+                                visible="false"
+                            ></a-cylinder>
                             </a-entity>
                         </template>
 
@@ -155,10 +167,11 @@
                     gltf-model-plus="src: #interactable-duck; inflate: true;"
                     scale="2 2 2"
                     class="interactable" 
-                    super-networked-interactable="counter: #counter; mass: 5;"
-                    body="type: dynamic; shape: none; mass: 5;"
+                    super-networked-interactable="counter: #counter; mass: 1;"
+                    body="type: dynamic; shape: none; mass: 1;"
                     grabbable
                     stretchable="useWorldPosition: true;"
+                    duck
                 ></a-entity>
             </template>
 
@@ -177,15 +190,6 @@
         <!-- Interactables -->
         <a-entity id="counter" networked-counter="max: 3; ttl: 120"></a-entity>
 
-        <a-entity 
-            gltf-model-plus="src: #interactable-duck"
-            scale="2 2 2"
-            class="interactable" 
-            super-spawner="template: #interactable-template;" 
-            position="2.9 1.2 0" 
-            body="mass: 0; type: static; shape: box;"
-        ></a-entity>
-
         <a-entity
             id="cursor-controller"
             cursor-controller="
@@ -219,7 +223,7 @@
         <a-entity
             id="player-rig"
             networked="template: #remote-avatar-template; attachTemplateToLocal: false;"
-            spawn-controller="radius: 4;"
+            spawn-controller="loadedEvent: bundleloaded; target: #environment-root"
             wasd-to-analog2d
             character-controller="pivot: #player-camera"
             ik-root
@@ -228,16 +232,18 @@
         >
             <a-entity
                 id="player-hud"
-                class="ui"
                 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="30 0 0">
                     <a-rounded height="0.13" width="0.48" color="#000000" position="-0.24 -0.065 0" radius="0.065" opacity="0.35" class="hud bg"></a-rounded>
-                    <a-image icon-button="image: #mute-off; hoverImage: #mute-off-hover; activeImage: #mute-on; activeHoverImage: #mute-on-hover" scale="0.1 0.1 0.1" position="-0.17 0 0.001" class="hud mic" material="alphaTest:0.1;"></a-image>
-                    <a-image icon-button="image: #freeze-off; hoverImage: #freeze-off-hover; activeImage: #freeze-on; activeHoverImage: #freeze-on-hover" scale="0.2 0.2 0.2" position="0 0 0.001" class="hud freeze"></a-image>
-                    <a-image icon-button="image: #bubble-off; hoverImage: #bubble-off-hover; activeImage: #bubble-on; activeHoverImage: #bubble-on-hover" scale="0.1 0.1 0.1" position="0.17 0 0.001" class="hud bubble" material="alphaTest:0.1;"></a-image>
+                    <a-image icon-button="tooltip: #hud-tooltip; tooltipText: Mute Mic; activeTooltipText: Unmute Mic; image: #mute-off; hoverImage: #mute-off-hover; activeImage: #mute-on; activeHoverImage: #mute-on-hover" scale="0.1 0.1 0.1" position="-0.17 0 0.001" class="ui hud mic" material="alphaTest:0.1;"></a-image>
+                    <a-image icon-button="tooltip: #hud-tooltip; tooltipText: Pause; activeTooltipText: Resume; image: #freeze-off; hoverImage: #freeze-off-hover; activeImage: #freeze-on; activeHoverImage: #freeze-on-hover" scale="0.2 0.2 0.2" position="0 0 0.005" class="ui hud freeze"></a-image>
+                    <a-image icon-button="tooltip: #hud-tooltip; tooltipText: Enable Bubble; activeTooltipText: Disable Bubble; image: #bubble-off; hoverImage: #bubble-off-hover; activeImage: #bubble-on; activeHoverImage: #bubble-on-hover" scale="0.1 0.1 0.1" position="0.17 0 0.001" class="ui hud bubble" material="alphaTest:0.1;"></a-image>
+                    <a-rounded visible="false" id="hud-tooltip" height="0.08" width="0.3" color="#000000" position="-0.15 -0.2 0" rotation="-20 0 0" radius="0.025" opacity="0.35" class="hud bg">
+                        <a-entity text="value: Mute Mic; align:center;" position="0.15 0.04 0.001" ></a-entity>
+                    </a-rounded>
                 </a-entity>
             </a-entity>
 
@@ -278,7 +284,11 @@
                     hitOpacity: 0.3;
                     missOpacity: 0.2;"
                 haptic-feedback
-            ></a-entity>
+                body="type: static; shape: none;"
+                mixin="super-hands"
+                controls-shape-offset
+            >
+            </a-entity>
 
             <a-entity
                 id="player-right-controller"
@@ -295,6 +305,9 @@
                     hitOpacity: 0.3;
                     missOpacity: 0.2;"
                 haptic-feedback
+                body="type: static; shape: none;"
+                mixin="super-hands"
+                controls-shape-offset
             ></a-entity>
 
             <a-entity gltf-model-plus="inflate: true;"
@@ -321,67 +334,22 @@
                 </template>
 
                 <template data-selector=".LeftHand">
-                    <a-entity bone-visibility>
-                        <a-entity
-                            id="left-super-hand"
-                            event-repeater="
-                            events: action_grab, action_release, action_primary_down, action_primary_up; 
-                            eventSource: #player-left-controller"
-                            static-body="shape: sphere; sphereRadius: 0.02"
-                            mixin="super-hands"
-                            position="0 0.05 0"
-                        ></a-entity>
-                    </a-entity>
+                    <a-entity bone-visibility></a-entity>
                 </template>
 
                 <template data-selector=".RightHand">
-                    <a-entity bone-visibility>
-                        <a-entity
-                            id="right-super-hand"
-                            event-repeater="
-                            events: action_grab, action_release, action_primary_down, action_primary_up; 
-                            eventSource: #player-right-controller"
-                            static-body="shape: sphere; sphereRadius: 0.02"
-                            mixin="super-hands"
-                            position="0 -0.05 0"
-                        ></a-entity>
-                    </a-entity>
+                    <a-entity bone-visibility></a-entity>
                 </template>
 
             </a-entity>
         </a-entity>
 
-        <!-- Lights -->
-        <a-entity
-            hide-when-quality="low"
-            light="type: directional; color: #F9FFCE; intensity: 0.6"
-            position="0.002 5.231 -15.3"
-        ></a-entity>
-
         <!-- Environment -->
         <a-entity 
             id="environment-root" 
             nav-mesh-helper
             static-body="shape: none;"
         ></a-entity>
-
-        <a-entity
-            id="skybox"
-            scale="8000 8000 8000"
-            skybox="azimuth:0.280; inclination:0.440"
-            light="type: ambient; color: #FFF"
-            layers="reflection:true"
-            xr="ar: false"
-        ></a-entity>
-
-        <a-entity
-            id="water"
-            water="forceMobile: true; normalMap:#water-normal-map"
-            rotation="-90 0 0"
-            position="0 -88.358 -332.424"
-            xr="ar: false"
-        ></a-entity>
-
     </a-scene>
 
     <div id="ui-root"></div>
diff --git a/src/hub.js b/src/hub.js
index 26b9e1f368908a0b94a4612193cd722d1b11f38b..554d1f3a7a449f3fd22571b7a251c6690ee74b77 100644
--- a/src/hub.js
+++ b/src/hub.js
@@ -57,6 +57,8 @@ import "./components/block-button";
 import "./components/visible-while-frozen";
 import "./components/stats-plus";
 import "./components/networked-avatar";
+import "./components/css-class";
+import "./components/scene-shadow";
 
 import ReactDOM from "react-dom";
 import React from "react";
@@ -68,6 +70,7 @@ import "./systems/app-mode";
 import "./systems/exit-on-blur";
 
 import "./gltf-component-mappings";
+import { DEFAULT_ENVIRONMENT_URL } from "./assets/environments/environments";
 
 import { App } from "./App";
 
@@ -91,6 +94,9 @@ import "./components/super-networked-interactable";
 import "./components/networked-counter";
 import "./components/super-spawner";
 import "./components/event-repeater";
+import "./components/controls-shape-offset";
+import "./components/duck";
+import "./components/quack";
 
 import "./components/cursor-controller";
 
@@ -308,8 +314,24 @@ const onReady = async () => {
     }
   };
 
+  const getPlatformUnsupportedReason = () => {
+    if (typeof RTCDataChannelEvent === "undefined") {
+      return "no_data_channels";
+    }
+
+    return null;
+  };
+
   remountUI({ enterScene, exitScene });
 
+  const platformUnsupportedReason = getPlatformUnsupportedReason();
+
+  if (platformUnsupportedReason) {
+    remountUI({ platformUnsupportedReason: platformUnsupportedReason });
+    exitScene();
+    return;
+  }
+
   getAvailableVREntryTypes().then(availableVREntryTypes => {
     remountUI({ availableVREntryTypes });
   });
@@ -328,10 +350,7 @@ const onReady = async () => {
     // If ?room is set, this is `yarn start`, so just use a default environment and query string room.
     remountUI({ janusRoomId: qs.room && !isNaN(parseInt(qs.room)) ? parseInt(qs.room) : 1 });
     initialEnvironmentEl.setAttribute("gltf-bundle", {
-      src: "https://asset-bundles-prod.reticulum.io/rooms/meetingroom/MeetingRoom.bundle.json"
-      // src: "https://asset-bundles-prod.reticulum.io/rooms/theater/TheaterMeshes.bundle.json"
-      // src: "https://asset-bundles-prod.reticulum.io/rooms/atrium/AtriumMeshes.bundle.json"
-      // src: "https://asset-bundles-prod.reticulum.io/rooms/courtyard/CourtyardMeshes.bundle.json"
+      src: DEFAULT_ENVIRONMENT_URL
     });
     return;
   }
diff --git a/src/input-mappings.js b/src/input-mappings.js
index a5c399937f0af8d129e2a177a5175bfeeb27efbd..f9985498157ac70bec292eacfe241dfbb5927355 100644
--- a/src/input-mappings.js
+++ b/src/input-mappings.js
@@ -26,6 +26,9 @@ const config = {
       "vive-controls": {
         trackpad: "trackpad_dpad4"
       },
+      "windows-motion-controls": {
+        joystick: "joystick_dpad4"
+      },
       "daydream-controls": {
         trackpad: "trackpad_dpad4"
       },
@@ -80,6 +83,28 @@ const config = {
         abuttondown: "action_primary_down",
         abuttonup: "action_primary_up"
       },
+      "windows-motion-controls": {
+        joystick_dpad4_west: {
+          right: "snap_rotate_left"
+        },
+        joystick_dpad4_east: {
+          right: "snap_rotate_right"
+        },
+        "trackpad.pressedmove": { left: "move" },
+        joystick_dpad4_pressed_west_down: { right: "snap_rotate_left" },
+        joystick_dpad4_pressed_east_down: { right: "snap_rotate_right" },
+        trackpaddown: { right: "action_primary_down" },
+        trackpadup: { right: "action_primary_up" },
+        menudown: "thumb_down",
+        menuup: "thumb_up",
+        gripdown: ["action_grab", "middle_ring_pinky_down"],
+        gripup: ["action_release", "middle_ring_pinky_up"],
+        trackpadtouchstart: "thumb_down",
+        trackpadtouchend: "thumb_up",
+        triggerdown: ["action_grab", "index_down"],
+        triggerup: ["action_release", "index_up"],
+        "axismove.reverseY": { left: "move" }
+      },
       "daydream-controls": {
         trackpad_dpad4_pressed_west_down: "snap_rotate_left",
         trackpad_dpad4_pressed_east_down: "snap_rotate_right",
diff --git a/src/react-components/2d-hud.js b/src/react-components/2d-hud.js
index eff928870432936ca41843202bbdfc073adaf032..141606a83d121f0a280ac5ac6b6d997856f7fc1e 100644
--- a/src/react-components/2d-hud.js
+++ b/src/react-components/2d-hud.js
@@ -7,15 +7,21 @@ import styles from "../assets/stylesheets/2d-hud.scss";
 const TwoDHUD = ({ muted, frozen, spacebubble, onToggleMute, onToggleFreeze, onToggleSpaceBubble }) => (
   <div className={styles.container}>
     <div className={cx("ui-interactive", styles.panel, styles.left)}>
-      <div className={cx(styles.iconButton, styles.mute, { [styles.active]: muted })} onClick={onToggleMute} />
+      <div
+        className={cx(styles.iconButton, styles.mute, { [styles.active]: muted })}
+        title={muted ? "Unmute Mic" : "Mute Mic"}
+        onClick={onToggleMute}
+      />
     </div>
     <div
       className={cx("ui-interactive", styles.iconButton, styles.large, styles.freeze, { [styles.active]: frozen })}
+      title={frozen ? "Resume" : "Pause"}
       onClick={onToggleFreeze}
     />
     <div className={cx("ui-interactive", styles.panel, styles.right)}>
       <div
         className={cx(styles.iconButton, styles.bubble, { [styles.active]: spacebubble })}
+        title={spacebubble ? "Disable Bubble" : "Enable Bubble"}
         onClick={onToggleSpaceBubble}
       />
     </div>
diff --git a/src/react-components/home-root.js b/src/react-components/home-root.js
index 6e780474b5ecf98164f6e34cc47e6dcc9a0551ac..401270832f397dc03039f67e84487173a461dd80 100644
--- a/src/react-components/home-root.js
+++ b/src/react-components/home-root.js
@@ -4,6 +4,7 @@ import { IntlProvider, FormattedMessage, addLocaleData } from "react-intl";
 import en from "react-intl/locale-data/en";
 import homeVideo from "../assets/video/home.webm";
 import classNames from "classnames";
+import { ENVIRONMENT_URLS } from "../assets/environments/environments";
 
 import HubCreatePanel from "./hub-create-panel.js";
 import InfoDialog from "./info-dialog.js";
@@ -17,14 +18,6 @@ addLocaleData([...en]);
 
 const messages = localeData[lang] || localeData.en;
 
-const ENVIRONMENT_URLS = [
-  "https://asset-bundles-prod.reticulum.io/rooms/meetingroom/MeetingRoom.bundle.json",
-  "https://asset-bundles-prod.reticulum.io/rooms/theater/Theater.bundle.json",
-  "https://asset-bundles-prod.reticulum.io/rooms/atrium/Atrium.bundle.json",
-  "https://asset-bundles-prod.reticulum.io/rooms/courtyard/Courtyard.bundle.json",
-  "https://asset-bundles-prod.reticulum.io/rooms/MedievalFantasyBook/MedievalFantasyBook.bundle.json"
-];
-
 class HomeRoot extends Component {
   static propTypes = {
     intl: PropTypes.object,
diff --git a/src/react-components/hub-create-panel.js b/src/react-components/hub-create-panel.js
index 0b74d55b2698d51f6688e23e02e871177ecea436..f925d6ae55d82e7019f68658d3f7c03a986a038e 100644
--- a/src/react-components/hub-create-panel.js
+++ b/src/react-components/hub-create-panel.js
@@ -6,6 +6,7 @@ import classNames from "classnames";
 import faAngleLeft from "@fortawesome/fontawesome-free-solid/faAngleLeft";
 import faAngleRight from "@fortawesome/fontawesome-free-solid/faAngleRight";
 import FontAwesomeIcon from "@fortawesome/react-fontawesome";
+import { resolveURL, extractUrlBase } from "../utils/resolveURL";
 
 import default_scene_preview_thumbnail from "../assets/images/default_thumbnail.png";
 
@@ -42,11 +43,24 @@ class HubCreatePanel extends Component {
   _getEnvironmentThumbnail = environmentIndex => {
     const environment = this.props.environments[environmentIndex];
     const meta = environment.meta || {};
-    return (
-      (meta.images || []).find(i => i.type === "preview-thumbnail") || {
-        srcset: default_scene_preview_thumbnail
+
+    let environmentThumbnail = {
+      srcset: default_scene_preview_thumbnail
+    };
+
+    if (meta.images) {
+      const thumbnailImage = meta.images.find(i => i.type === "preview-thumbnail");
+
+      if (thumbnailImage) {
+        const baseURL = new URL(extractUrlBase(environment.bundle_url), window.location.href);
+
+        environmentThumbnail = {
+          srcset: resolveURL(thumbnailImage.srcset, baseURL)
+        };
       }
-    );
+    }
+
+    return environmentThumbnail;
   };
 
   createHub = async e => {
diff --git a/src/react-components/ui-root.js b/src/react-components/ui-root.js
index 43d39ba0a1e442f55dc095b29e63b64096be68ae..ab1d0229b0b529a6e3d9131df82e683f6097e11a 100644
--- a/src/react-components/ui-root.js
+++ b/src/react-components/ui-root.js
@@ -69,6 +69,7 @@ class UIRoot extends Component {
     initialEnvironmentLoaded: PropTypes.bool,
     janusRoomId: PropTypes.number,
     roomUnavailableReason: PropTypes.string,
+    platformUnsupportedReason: PropTypes.string,
     hubName: PropTypes.string,
     occupantCount: PropTypes.number
   };
@@ -512,18 +513,9 @@ class UIRoot extends Component {
   };
 
   render() {
-    if (this.state.exited || this.props.roomUnavailableReason) {
+    if (this.state.exited || this.props.roomUnavailableReason || this.props.platformUnsupportedReason) {
       let subtitle = null;
-      if (this.props.roomUnavailableReason !== "closed") {
-        const exitSubtitleId = `exit.subtitle.${this.state.exited ? "exited" : this.props.roomUnavailableReason}`;
-        subtitle = (
-          <div>
-            <FormattedMessage id={exitSubtitleId} />
-            <p />
-            You can also <a href="/">create a new room</a>.
-          </div>
-        );
-      } else {
+      if (this.props.roomUnavailableReason === "closed") {
         // TODO i18n, due to links and markup
         subtitle = (
           <div>
@@ -537,7 +529,34 @@ class UIRoot extends Component {
             If you have questions, contact us at <a href="mailto:hubs@mozilla.com">hubs@mozilla.com</a>.
             <p />
             If you&apos;d like to run your own server, hubs&apos;s source code is available on{" "}
-            <a href="https://github.com/mozilla/hubs">Github</a>.
+            <a href="https://github.com/mozilla/hubs">GitHub</a>.
+          </div>
+        );
+      } else if (this.props.platformUnsupportedReason === "no_data_channels") {
+        // TODO i18n, due to links and markup
+        subtitle = (
+          <div>
+            Your browser does not support{" "}
+            <a
+              href="https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/createDataChannel#Browser_compatibility"
+              rel="noreferrer noopener"
+            >
+              WebRTC Data Channels
+            </a>, which is required to use Hubs.
+          </div>
+        );
+      } else {
+        const reason = this.props.roomUnavailableReason || this.props.platformUnsupportedReason;
+        const exitSubtitleId = `exit.subtitle.${this.state.exited ? "exited" : reason}`;
+        subtitle = (
+          <div>
+            <FormattedMessage id={exitSubtitleId} />
+            <p />
+            {this.props.roomUnavailableReason && (
+              <div>
+                You can also <a href="/">create a new room</a>.
+              </div>
+            )}
           </div>
         );
       }
diff --git a/src/utils/resolveURL.js b/src/utils/resolveURL.js
index ddc6c86803b9adc7ea028b2b127ae0783270f521..35ccc3150278558e4026240164116785620ecfc3 100644
--- a/src/utils/resolveURL.js
+++ b/src/utils/resolveURL.js
@@ -15,3 +15,11 @@ export function resolveURL(url, path) {
   // Relative URL
   return path + url;
 }
+
+export function extractUrlBase(url) {
+  const index = url.lastIndexOf("/");
+
+  if (index === -1) return "./";
+
+  return url.substr(0, index + 1);
+}
diff --git a/webpack.config.js b/webpack.config.js
index c0f8c88b3af3a6c2e43f7111f517841c4fcfec3c..4fc219ef830791bd6dbc6d201143ba82af6b961c 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -206,6 +206,12 @@ const config = {
         to: "favicon.ico"
       }
     ]),
+    new CopyWebpackPlugin([
+      {
+        from: "src/assets/images/hub-preview.png",
+        to: "hub-preview.png"
+      }
+    ]),
     // Extract required css and add a content hash.
     new ExtractTextPlugin({
       filename: "assets/stylesheets/[name]-[contenthash].css",
@@ -228,7 +234,8 @@ const config = {
       "process.env": JSON.stringify({
         NODE_ENV: process.env.NODE_ENV,
         JANUS_SERVER: process.env.JANUS_SERVER,
-        DEV_RETICULUM_SERVER: process.env.DEV_RETICULUM_SERVER
+        DEV_RETICULUM_SERVER: process.env.DEV_RETICULUM_SERVER,
+        ASSET_BUNDLE_SERVER: process.env.ASSET_BUNDLE_SERVER
       })
     })
   ]
diff --git a/yarn.lock b/yarn.lock
index 321a63370a4e9eb13191ae2835ed429b7eb6c242..a9e1e2e95bf3783fdc9550bdcce76c190a646d44 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -166,9 +166,9 @@ aframe-billboard-component@^1.0.0:
   dependencies:
     three-pathfinding "^0.5.5"
 
-"aframe-input-mapping-component@https://github.com/johnshaughnessy/aframe-input-mapping-component#feature/map-to-array":
+"aframe-input-mapping-component@https://github.com/mozillareality/aframe-input-mapping-component#hubs/master":
   version "0.1.2"
-  resolved "https://github.com/johnshaughnessy/aframe-input-mapping-component#33d7ad4c82a5e2b74defca39c7fa5ef15aab493e"
+  resolved "https://github.com/mozillareality/aframe-input-mapping-component#03932457c5318db243e811d2767fe0c5a8c7e9e0"
 
 "aframe-physics-extras@https://github.com/infinitelee/aframe-physics-extras#fix/physics-collider-crash":
   version "0.1.2"