diff --git a/README.md b/README.md
index a3e7026c4e4d15a72fbdef4629ddc756f579f112..d42dacae191ad8c4902202e5ca08fbb7b8a72404 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,12 @@ To bundle javascript and generate the html templates, run:
 yarn build
 ```
 
+## hubs.local Host Entry
+
+When running the full stack for Hubs (which includes [Reticulum](https://github.com/mozilla/reticulum))
+locally it is necessary to add a `hosts` entry pointing `hubs.local` to your local server's IP.
+This will allow the CSP checks to pass that are served up by Reticulum so you can test the whole app.
+
 ## Query Params
 
 - `room` - Id of the room (an integer) that you want to join
@@ -31,7 +37,7 @@ yarn build
 - `quality` - Either "low" or "high". Force assets to a certain quality level
 - `mobile` - Force mobile mode
 - `no_stats` - Disable performance stats
-- `vr_entry_type` - Either "gearvr" or "daydream". Used internally to force a VR entry type
+- `vr_entry_type` - Either "2d", "vr", or "daydream". Used internally to force a VR entry type. Add "_now" to the end of the value to skip the audio check.
 - `disable_telemetry` - If `true` disables Sentry telemetry.
 - `log_filter` - A `debug` style filter for setting the logging level.
 - `debug` - If `true` performs verbose logging of Janus and NAF traffic.
diff --git a/scripts/bot/run-bot.js b/scripts/bot/run-bot.js
old mode 100644
new mode 100755
index cadf6aaf2a1a2a444b290fc64cb2ad1cba3a895a..dc3cb0160099bb1e437af9c8ea3065e7eb84c0aa
--- a/scripts/bot/run-bot.js
+++ b/scripts/bot/run-bot.js
@@ -2,7 +2,6 @@
 const doc = `
 Usage:
     ./run-bot.js [options]
-
 Options:
     -u --url=<url>    URL
     -o --host=<host>  Hubs host if URL is not specified [default: localhost:8080]
@@ -16,12 +15,20 @@ const options = docopt(doc);
 const puppeteer = require("puppeteer");
 const querystring = require("query-string");
 
+function log(...objs) {
+  console.log.call(null, [new Date().toISOString()].concat(objs).join(" "));
+}
+
+function error(...objs) {
+  console.error.call(null, [new Date().toISOString()].concat(objs).join(" "));
+}
+
 (async () => {
   const browser = await puppeteer.launch({ ignoreHTTPSErrors: true });
   const page = await browser.newPage();
-  page.on("console", msg => console.log("PAGE: ", msg.text()));
-  page.on("error", err => console.error("ERROR: ", err.toString().split("\n")[0]));
-  page.on("pageerror", err => console.error("PAGE ERROR: ", err.toString().split("\n")[0]));
+  page.on("console", msg => log("PAGE: ", msg.text()));
+  page.on("error", err => error("ERROR: ", err.toString().split("\n")[0]));
+  page.on("pageerror", err => error("PAGE ERROR: ", err.toString().split("\n")[0]));
 
   const baseUrl = options["--url"] || `https://${options["--host"]}/hub.html`;
 
@@ -35,11 +42,11 @@ const querystring = require("query-string");
   }
 
   const url = `${baseUrl}?${querystring.stringify(params)}`;
-  console.log(url);
+  log(url);
 
   const navigate = async () => {
     try {
-      console.log("Spawning bot...");
+      log("Spawning bot...");
       await page.goto(url);
       await page.evaluate(() => console.log(navigator.userAgent));
       let retryCount = 5;
@@ -50,14 +57,14 @@ const querystring = require("query-string");
           await page.mouse.click(100, 100);
           // Signal that the page has been interacted with.
           await page.evaluate(() => window.interacted());
-          console.log("Interacted.");
+          log("Interacted.");
         } catch (e) {
-          console.log("Interaction error", e.message);
+          log("Interaction error", e.message);
           if (retryCount-- < 0) {
             // If retries failed, throw and restart navigation.
             throw new Error("Retries failed");
           }
-          console.log("Retrying...");
+          log("Retrying...");
           backoff *= 2;
           // Retry interaction to start audio playback
           setTimeout(interact, backoff);
@@ -65,7 +72,7 @@ const querystring = require("query-string");
       };
       await interact();
     } catch (e) {
-      console.log("Navigation error", e.message);
+      log("Navigation error", e.message);
       setTimeout(navigate, 1000);
     }
   };
diff --git a/scripts/build_local_reticulum.sh b/scripts/build_local_reticulum.sh
index 3f5e3a00136a84adf99b68926ee0dd1b17a09af3..9a19f9f202b688b27213b0478f9e1a14e67d2620 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 ; GENERATE_SMOKE_TESTS=true BASE_ASSETS_PATH=https://localhost:4000/ yarn build -- --output-path ../reticulum/priv/static 
+rm -rf ../reticulum/priv/static ; GENERATE_SMOKE_TESTS=true BASE_ASSETS_PATH=https://hubs.local:4000/ yarn build -- --output-path ../reticulum/priv/static 
diff --git a/src/assets/translations.data.json b/src/assets/translations.data.json
index 0201cf0fca4a81f60217ab97679f6f2e7316fc43..79d11599544849a83f9982617dafddd3bb6c268a 100644
--- a/src/assets/translations.data.json
+++ b/src/assets/translations.data.json
@@ -3,6 +3,7 @@
     "entry.screen-prefix": "Enter on ",
     "entry.desktop-screen": "Screen",
     "entry.mobile-screen": "Phone",
+    "entry.mobile-safari": "Safari",
     "entry.generic-prefix": "Enter in ",
     "entry.generic-medium": "VR",
     "entry.generic-subtitle-desktop": "Oculus or SteamVR",
diff --git a/src/avatar-selector.html b/src/avatar-selector.html
index 96dd9c67463ec3ddda58956463b2b136b005ed27..d125ca51622c0ed77dec9b1285e2a1b0380c8a27 100644
--- a/src/avatar-selector.html
+++ b/src/avatar-selector.html
@@ -6,9 +6,9 @@
   <title>avatar selector</title>
   <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
   <% if(NODE_ENV === "production") { %>
-    <script src="https://cdn.rawgit.com/aframevr/aframe/3e7a4b3/dist/aframe-master.min.js" integrity="sha384-LQXa4VjhYucs9sVd5yQ3OhBXRea0jrvbHJA8CYLgTnvzxF5uvyhabSo1mX4tT2c6" crossorigin="anonymous"></script>
+  <script src="https://cdn.rawgit.com/aframevr/aframe/1be48d9/dist/aframe-master.min.js" integrity="sha384-SXrfoMHbXpA5RZhIyhgaR6tQ764dDZsbFk3PiokC/tc0+NnW1yaYQMUzWtL06hnq" crossorigin="anonymous"></script>
   <% } else { %>
-    <script src="https://cdn.rawgit.com/aframevr/aframe/3e7a4b3/dist/aframe-master.js" integrity="sha384-EaMOuyBOi9ERV/lVDwQgz/yFWBDWPsIju5Co6oCZZHXuvbLBO81yPWn80q0BbBn3" crossorigin="anonymous"></script>
+  <script src="https://cdn.rawgit.com/aframevr/aframe/1be48d9/dist/aframe-master.js" integrity="sha384-AmjDGOMbvTrrUFdeVWcBIlXRINIWnO8iwj/4VS21OWbYDsa/7nheOIyPAPJSkR6J" crossorigin="anonymous"></script>
   <% } %>
 </head>
 
diff --git a/src/components/audio-feedback.js b/src/components/audio-feedback.js
index 7edf3ec3f654eb9ee7b45c35c58d2afee2c56373..aa7182fa7329145078a1ff27270700164444efff 100644
--- a/src/components/audio-feedback.js
+++ b/src/components/audio-feedback.js
@@ -4,8 +4,8 @@
  * @component networked-audio-analyser
  */
 AFRAME.registerComponent("networked-audio-analyser", {
-  schema: {},
   async init() {
+    this.volume = 0;
     this.el.addEventListener("sound-source-set", event => {
       const ctx = THREE.AudioContext.getContext();
       this.analyser = ctx.createAnalyser();
@@ -25,10 +25,6 @@ AFRAME.registerComponent("networked-audio-analyser", {
       sum += this.levels[i];
     }
     this.volume = sum / this.levels.length;
-    this.el.emit("audioFrequencyChange", {
-      volume: this.volume,
-      levels: this.levels
-    });
   }
 });
 
@@ -37,24 +33,12 @@ AFRAME.registerComponent("networked-audio-analyser", {
  * @component matcolor-audio-feedback
  */
 AFRAME.registerComponent("matcolor-audio-feedback", {
-  schema: {
-    analyserSrc: { type: "selector" }
-  },
-  init: function() {
-    this.onAudioFrequencyChange = this.onAudioFrequencyChange.bind(this);
-  },
+  tick() {
+    const audioAnalyser = this.el.components["networked-audio-analyser"];
 
-  play() {
-    (this.data.analyserSrc || this.el).addEventListener("audioFrequencyChange", this.onAudioFrequencyChange);
-  },
+    if (!audioAnalyser || !this.mat) return;
 
-  pause() {
-    (this.data.analyserSrc || this.el).removeEventListener("audioFrequencyChange", this.onAudioFrequencyChange);
-  },
-
-  onAudioFrequencyChange(e) {
-    if (!this.mat) return;
-    this.object3D.mesh.color.setScalar(1 + e.detail.volume / 255 * 2);
+    this.object3D.mesh.color.setScalar(1 + audioAnalyser.volume / 255 * 2);
   }
 });
 
@@ -65,29 +49,21 @@ AFRAME.registerComponent("matcolor-audio-feedback", {
  */
 AFRAME.registerComponent("scale-audio-feedback", {
   schema: {
-    analyserSrc: { type: "selector" },
-
     minScale: { default: 1 },
     maxScale: { default: 2 }
   },
 
-  init() {
-    this.onAudioFrequencyChange = this.onAudioFrequencyChange.bind(this);
-  },
-
-  play() {
-    (this.data.analyserSrc || this.el).addEventListener("audioFrequencyChange", this.onAudioFrequencyChange);
-  },
-
-  pause() {
-    (this.data.analyserSrc || this.el).removeEventListener("audioFrequencyChange", this.onAudioFrequencyChange);
-  },
-
-  onAudioFrequencyChange(e) {
+  tick() {
     // TODO: come up with a cleaner way to handle this.
     // bone's are "hidden" by scaling them with bone-visibility, without this we would overwrite that.
     if (!this.el.object3D.visible) return;
+
     const { minScale, maxScale } = this.data;
-    this.el.object3D.scale.setScalar(minScale + (maxScale - minScale) * e.detail.volume / 255);
+
+    const audioAnalyser = this.el.components["networked-audio-analyser"];
+
+    if (!audioAnalyser) return;
+
+    this.el.object3D.scale.setScalar(minScale + (maxScale - minScale) * audioAnalyser.volume / 255);
   }
 });
diff --git a/src/components/character-controller.js b/src/components/character-controller.js
index d3e36432d93217d812e54e6a1ca9531d1bd12fb3..f32debe666107ccfb6144f94e7ca5afe3f9f4168 100644
--- a/src/components/character-controller.js
+++ b/src/components/character-controller.js
@@ -125,23 +125,25 @@ AFRAME.registerComponent("character-controller", {
       yawMatrix.makeRotationAxis(rotationAxis, rotationDelta);
 
       // Translate to middle of playspace (player rig)
-      root.applyMatrix(transInv);
+      root.matrix.premultiply(transInv);
       // Zero playspace (player rig) rotation
-      root.applyMatrix(rotationInvMatrix);
+      root.matrix.premultiply(rotationInvMatrix);
       // Zero pivot (camera/head) rotation
-      root.applyMatrix(pivotRotationInvMatrix);
+      root.matrix.premultiply(pivotRotationInvMatrix);
       // Apply joystick translation
-      root.applyMatrix(move);
+      root.matrix.premultiply(move);
       // Apply joystick yaw rotation
-      root.applyMatrix(yawMatrix);
+      root.matrix.premultiply(yawMatrix);
       // Apply snap rotation if necessary
-      root.applyMatrix(this.pendingSnapRotationMatrix);
+      root.matrix.premultiply(this.pendingSnapRotationMatrix);
       // Reapply pivot (camera/head) rotation
-      root.applyMatrix(pivotRotationMatrix);
+      root.matrix.premultiply(pivotRotationMatrix);
       // Reapply playspace (player rig) rotation
-      root.applyMatrix(rotationMatrix);
+      root.matrix.premultiply(rotationMatrix);
       // Reapply playspace (player rig) translation
-      root.applyMatrix(trans);
+      root.matrix.premultiply(trans);
+      // update pos/rot/scale
+      root.matrix.decompose(root.position, root.quaternion, root.scale);
 
       // TODO: the above matrix trnsfomraitons introduce some floating point errors in scale, this reverts them to
       // avoid spamming network with fake scale updates
diff --git a/src/components/cursor-controller.js b/src/components/cursor-controller.js
index 83aea2193fd285c04cbda8946880134dc3d9120f..e0dda6f0bce57127c09ef5580bf84a497ad2caf8 100644
--- a/src/components/cursor-controller.js
+++ b/src/components/cursor-controller.js
@@ -28,8 +28,8 @@ AFRAME.registerComponent("cursor-controller", {
     this.wasCursorHovered = false;
     this.origin = new THREE.Vector3();
     this.direction = new THREE.Vector3();
+    this.raycasterAttr = this.el.getAttribute("raycaster");
     this.controllerQuaternion = new THREE.Quaternion();
-
     this.data.cursor.setAttribute("material", { color: this.data.cursorColorUnhovered });
 
     this._handleCursorLoaded = this._handleCursorLoaded.bind(this);
@@ -45,6 +45,12 @@ AFRAME.registerComponent("cursor-controller", {
     this.setCursorVisibility(false);
   },
 
+  updateRay: function() {
+    this.raycasterAttr.origin = this.origin;
+    this.raycasterAttr.direction = this.direction;
+    this.el.setAttribute("raycaster", this.raycasterAttr, true);
+  },
+
   tick: (() => {
     const rayObjectRotation = new THREE.Quaternion();
 
@@ -63,7 +69,7 @@ AFRAME.registerComponent("cursor-controller", {
           .applyQuaternion(rayObjectRotation)
           .normalize();
         this.origin.setFromMatrixPosition(rayObject.matrixWorld);
-        this.el.setAttribute("raycaster", { origin: this.origin, direction: this.direction });
+        this.updateRay();
       }
 
       const isGrabbing = this.data.cursor.components["super-hands"].state.has("grab-start");
@@ -100,7 +106,7 @@ AFRAME.registerComponent("cursor-controller", {
     raycaster.setFromCamera(this.mousePos, camera);
     this.origin.copy(raycaster.ray.origin);
     this.direction.copy(raycaster.ray.direction);
-    this.el.setAttribute("raycaster", { origin: raycaster.ray.origin, direction: raycaster.ray.direction });
+    this.updateRay();
   },
 
   updateDistanceAndTargetType: function() {
diff --git a/src/components/look-on-mobile.js b/src/components/look-on-mobile.js
index deb553e787f96a349d7b690901e5df9bb96ab892..7cde5ba136933c7d658693f00e3a3ea3a3316307 100644
--- a/src/components/look-on-mobile.js
+++ b/src/components/look-on-mobile.js
@@ -1,4 +1,3 @@
-const PolyfillControls = AFRAME.utils.device.PolyfillControls;
 const TWOPI = Math.PI * 2;
 
 class CircularBuffer {
@@ -50,24 +49,23 @@ AFRAME.registerComponent("look-on-mobile", {
 
   init() {
     this.hmdEuler = new THREE.Euler();
+    this.hmdQuaternion = new THREE.Quaternion();
     this.prevX = this.hmdEuler.x;
     this.prevY = this.hmdEuler.y;
     this.pendingLookX = 0;
     this.onRotateX = this.onRotateX.bind(this);
     this.dXBuffer = new CircularBuffer(6);
     this.dYBuffer = new CircularBuffer(6);
+    this.vrDisplay = window.webvrpolyfill.getPolyfillDisplays()[0];
+    this.frameData = new window.webvrpolyfill.constructor.VRFrameData();
   },
 
   play() {
     this.el.addEventListener("rotateX", this.onRotateX);
-    this.polyfillObject = new THREE.Object3D();
-    this.polyfillControls = new PolyfillControls(this.polyfillObject);
   },
 
   pause() {
     this.el.removeEventListener("rotateX", this.onRotateX);
-    this.polyfillControls = null;
-    this.polyfillObject = null;
   },
 
   update() {
@@ -81,8 +79,11 @@ AFRAME.registerComponent("look-on-mobile", {
   tick() {
     const hmdEuler = this.hmdEuler;
     const { horizontalLookSpeedRatio, verticalLookSpeedRatio } = this.data;
-    this.polyfillControls.update();
-    hmdEuler.setFromQuaternion(this.polyfillObject.quaternion, "YXZ");
+    this.vrDisplay.getFrameData(this.frameData);
+    if (this.frameData.pose.orientation !== null) {
+      this.hmdQuaternion.fromArray(this.frameData.pose.orientation);
+      hmdEuler.setFromQuaternion(this.hmdQuaternion, "YXZ");
+    }
 
     const dX = THREE.Math.RAD2DEG * difference(hmdEuler.x, this.prevX);
     const dY = THREE.Math.RAD2DEG * difference(hmdEuler.y, this.prevY);
diff --git a/src/hub.html b/src/hub.html
index 9863b8361c91da6eaf2132597ade00307b14195b..a3b4cb48d94e1f517688677f9aa77128649dfc7e 100644
--- a/src/hub.html
+++ b/src/hub.html
@@ -13,12 +13,11 @@
     <link href="https://fonts.googleapis.com/css?family=Zilla+Slab:300,300i,400,400i,700" rel="stylesheet">
 
     <% if(NODE_ENV === "production") { %>
-        <script src="https://cdn.rawgit.com/aframevr/aframe/3e7a4b3/dist/aframe-master.min.js" integrity="sha384-LQXa4VjhYucs9sVd5yQ3OhBXRea0jrvbHJA8CYLgTnvzxF5uvyhabSo1mX4tT2c6" crossorigin="anonymous"></script>
+    <script src="https://cdn.rawgit.com/aframevr/aframe/1be48d9/dist/aframe-master.min.js" integrity="sha384-SXrfoMHbXpA5RZhIyhgaR6tQ764dDZsbFk3PiokC/tc0+NnW1yaYQMUzWtL06hnq" crossorigin="anonymous"></script>
     <% } else { %>
-        <script src="https://cdn.rawgit.com/aframevr/aframe/3e7a4b3/dist/aframe-master.js" integrity="sha384-EaMOuyBOi9ERV/lVDwQgz/yFWBDWPsIju5Co6oCZZHXuvbLBO81yPWn80q0BbBn3" crossorigin="anonymous"></script>
+    <script src="https://cdn.rawgit.com/aframevr/aframe/1be48d9/dist/aframe-master.js" integrity="sha384-AmjDGOMbvTrrUFdeVWcBIlXRINIWnO8iwj/4VS21OWbYDsa/7nheOIyPAPJSkR6J" crossorigin="anonymous"></script>
     <% } %>
 
-    
     <!-- HACK: this has to run after A-Frame but before our bundle, since A-Frame blows away the local storage setting -->
     <script src="https://cdn.rawgit.com/gfodor/ba8f88d9f34fe9cbe59a01ce3c48420d/raw/03e31f0ef7b9eac5e947bd39e440f34df0701f75/naf-janus-adapter-logging.js" integrity="sha384-4q1V8Q88oeCFriFefFo5uEUtMzbw6K116tFyC9cwbiPr6wEe7050l5HoJUxMvnzj" crossorigin="anonymous"></script>
 </head>
diff --git a/src/hub.js b/src/hub.js
index 4c60dec0b56f8fe9707fb53449a7fe991c9e9f08..95133f739dc7c80750370dc685d6fbc923463ac3 100644
--- a/src/hub.js
+++ b/src/hub.js
@@ -224,7 +224,7 @@ const onReady = async () => {
     const scene = document.querySelector("a-scene");
     if (scene) {
       if (scene.renderer) {
-        scene.renderer.animate(null); // Stop animation loop, TODO A-Frame should do this
+        scene.renderer.setAnimationLoop(null); // Stop animation loop, TODO A-Frame should do this
       }
       document.body.removeChild(scene);
     }
@@ -425,11 +425,11 @@ const onReady = async () => {
     if (!isBotMode) {
       // Stop rendering while the UI is up. We restart the render loop in enterScene.
       // Wait a tick plus some margin so that the environments actually render.
-      setTimeout(() => scene.renderer.animate(null), 100);
+      setTimeout(() => scene.renderer.setAnimationLoop(null), 100);
     } else {
       const noop = () => {};
       // Replace renderer with a noop renderer to reduce bot resource usage.
-      scene.renderer = { animate: noop, render: noop };
+      scene.renderer = { setAnimationLoop: noop, render: noop };
       document.body.style.display = "none";
     }
   });
diff --git a/src/network-schemas.js b/src/network-schemas.js
index a1c91e93357c21267ba40a9d25d71d2bedfc475e..7addf6702740df5e67d4ebd0dd2f92849e91f07e 100644
--- a/src/network-schemas.js
+++ b/src/network-schemas.js
@@ -16,7 +16,6 @@ function registerNetworkSchemas() {
       },
       {
         component: "rotation",
-        lerp: false,
         requiresNetworkUpdate: rotationRequiresUpdate
       },
       "scale",
diff --git a/src/react-components/entry-buttons.js b/src/react-components/entry-buttons.js
index 9e8302b3c7bbf9bd44edbaf6538100f11abf3fb8..f2551df70f99813c5e74be91e7e627de3374feab 100644
--- a/src/react-components/entry-buttons.js
+++ b/src/react-components/entry-buttons.js
@@ -87,6 +87,17 @@ export const DaydreamEntryButton = props => {
   return <EntryButton {...entryButtonProps} />;
 };
 
+export const SafariEntryButton = props => {
+  const entryButtonProps = {
+    ...props,
+    iconSrc: MobileScreenEntryImg,
+    prefixMessageId: "entry.screen-prefix",
+    mediumMessageId: "entry.mobile-safari"
+  };
+
+  return <EntryButton {...entryButtonProps} />;
+};
+
 export const DeviceEntryButton = props => {
   const entryButtonProps = {
     ...props,
diff --git a/src/react-components/info-dialog.js b/src/react-components/info-dialog.js
index 4b432e01cb5c04116e6c199d3a873eea8c2540c1..fe7c0e8df2419f751414bc05edcb003df64ce1b0 100644
--- a/src/react-components/info-dialog.js
+++ b/src/react-components/info-dialog.js
@@ -15,6 +15,7 @@ class InfoDialog extends Component {
     slack: Symbol("slack"),
     email_submitted: Symbol("email_submitted"),
     invite: Symbol("invite"),
+    safari: Symbol("safari"),
     updates: Symbol("updates"),
     report: Symbol("report"),
     help: Symbol("help"),
@@ -74,8 +75,8 @@ class InfoDialog extends Component {
     });
   };
 
-  copyLinkClicked = () => {
-    copy(this.shareLink);
+  copyLinkClicked = link => {
+    copy(link);
     this.setState({ copyLinkButtonText: "Copied!" });
   };
 
@@ -160,7 +161,35 @@ class InfoDialog extends Component {
                     <span>Share</span>
                   </button>
                 )}
-                <button className="invite-form__action-button" onClick={this.copyLinkClicked}>
+                <button
+                  className="invite-form__action-button"
+                  onClick={this.copyLinkClicked.bind(this, this.shareLink)}
+                >
+                  <span>{this.state.copyLinkButtonText}</span>
+                </button>
+              </div>
+            </div>
+          </div>
+        );
+        break;
+      case InfoDialog.dialogTypes.safari:
+        dialogTitle = "Open in Safari";
+        dialogBody = (
+          <div>
+            <div>Hubs does not support your current browser on iOS. Copy and paste this link directly in Safari.</div>
+            <div className="invite-form">
+              <input
+                type="text"
+                readOnly
+                onFocus={e => e.target.select()}
+                value={document.location}
+                className="invite-form__link_field"
+              />
+              <div className="invite-form__buttons">
+                <button
+                  className="invite-form__action-button"
+                  onClick={this.copyLinkClicked.bind(this, document.location)}
+                >
                   <span>{this.state.copyLinkButtonText}</span>
                 </button>
               </div>
diff --git a/src/react-components/ui-root.js b/src/react-components/ui-root.js
index 5014d6af494e2f4ea551e8eb174c03b381db8c79..e5c56aa01147f9680c3ee6aa7fcf6e44658f4523 100644
--- a/src/react-components/ui-root.js
+++ b/src/react-components/ui-root.js
@@ -11,7 +11,13 @@ import screenfull from "screenfull";
 
 import { lang, messages } from "../utils/i18n";
 import AutoExitWarning from "./auto-exit-warning";
-import { TwoDEntryButton, DeviceEntryButton, GenericEntryButton, DaydreamEntryButton } from "./entry-buttons.js";
+import {
+  TwoDEntryButton,
+  DeviceEntryButton,
+  GenericEntryButton,
+  DaydreamEntryButton,
+  SafariEntryButton
+} from "./entry-buttons.js";
 import { ProfileInfoHeader } from "./profile-info-header.js";
 import ProfileEntryPanel from "./profile-entry-panel";
 import InfoDialog from "./info-dialog.js";
@@ -159,11 +165,11 @@ class UIRoot extends Component {
   handleForcedVREntryType = () => {
     if (!this.props.forcedVREntryType) return;
 
-    if (this.props.forcedVREntryType === "daydream") {
+    if (this.props.forcedVREntryType.startsWith("daydream")) {
       this.enterDaydream();
-    } else if (this.props.forcedVREntryType === "vr") {
+    } else if (this.props.forcedVREntryType.startsWith("vr")) {
       this.enterVR();
-    } else if (this.props.forcedVREntryType === "2d") {
+    } else if (this.props.forcedVREntryType.startsWith("2d")) {
       this.enter2D();
     }
   };
@@ -250,7 +256,7 @@ class UIRoot extends Component {
 
     if (hasGrantedMic) {
       await this.setMediaStreamToDefault();
-      this.beginAudioSetup();
+      this.beginOrSkipAudioSetup();
     } else {
       this.setState({ entryStep: ENTRY_STEPS.mic_grant });
     }
@@ -260,6 +266,10 @@ class UIRoot extends Component {
     await this.performDirectEntryFlow(false);
   };
 
+  linkSafari = async () => {
+    this.setState({ infoDialogType: InfoDialog.dialogTypes.safari });
+  };
+
   enterVR = async () => {
     if (this.props.availableVREntryTypes.generic !== VR_DEVICE_AVAILABILITY.maybe) {
       await this.performDirectEntryFlow(true);
@@ -411,10 +421,10 @@ class UIRoot extends Component {
       if (hasAudio) {
         this.setState({ entryStep: ENTRY_STEPS.mic_granted });
       } else {
-        this.beginAudioSetup();
+        this.beginOrSkipAudioSetup();
       }
     } else {
-      this.beginAudioSetup();
+      this.beginOrSkipAudioSetup();
     }
   };
 
@@ -422,8 +432,12 @@ class UIRoot extends Component {
     this.setState({ showProfileEntry: false });
   };
 
-  beginAudioSetup = () => {
-    this.setState({ entryStep: ENTRY_STEPS.audio_setup });
+  beginOrSkipAudioSetup = () => {
+    if (!this.props.forcedVREntryType || !this.props.forcedVREntryType.endsWith("_now")) {
+      this.setState({ entryStep: ENTRY_STEPS.audio_setup });
+    } else {
+      setTimeout(this.onAudioReadyButton, 3000); // Need to wait otherwise input doesn't work :/
+    }
   };
 
   fetchMicDevices = () => {
@@ -616,9 +630,12 @@ class UIRoot extends Component {
       this.state.entryStep === ENTRY_STEPS.start ? (
         <div className="entry-panel">
           <div className="entry-panel__button-container">
-            {this.props.availableVREntryTypes.screen !== VR_DEVICE_AVAILABILITY.no && (
+            {this.props.availableVREntryTypes.screen === VR_DEVICE_AVAILABILITY.yes && (
               <TwoDEntryButton onClick={this.enter2D} />
             )}
+            {this.props.availableVREntryTypes.safari === VR_DEVICE_AVAILABILITY.maybe && (
+              <SafariEntryButton onClick={this.linkSafari} />
+            )}
             {this.props.availableVREntryTypes.generic !== VR_DEVICE_AVAILABILITY.no && (
               <GenericEntryButton onClick={this.enterVR} />
             )}
diff --git a/src/systems/personal-space-bubble.js b/src/systems/personal-space-bubble.js
index 0ba3cee5d48a1b5095031db89f9fa2cf6e490429..238c0b63f70c06502350b83dd354cbb256c755bf 100644
--- a/src/systems/personal-space-bubble.js
+++ b/src/systems/personal-space-bubble.js
@@ -74,13 +74,10 @@ AFRAME.registerSystem("personal-space-bubble", {
   tick() {
     if (!this.data.enabled) return;
 
-    // Update matrix positions once for each space bubble and space invader
-    for (let i = 0; i < this.bubbles.length; i++) {
-      this.bubbles[i].el.object3D.updateMatrixWorld(true);
-    }
+    // precondition for this stuff -- the bubbles and invaders need updated world matrices.
+    // right now this is satisfied because we update the world matrices in the character controller
 
     for (let i = 0; i < this.invaders.length; i++) {
-      this.invaders[i].el.object3D.updateMatrixWorld(true);
       this.invaders[i].setInvading(false);
     }
 
diff --git a/src/utils/vr-caps-detect.js b/src/utils/vr-caps-detect.js
index 224f1bb49ba60a0627808a73dc6c3139d7df8aac..8953f12a86f24e9848c08e3619d718a2b0eda615 100644
--- a/src/utils/vr-caps-detect.js
+++ b/src/utils/vr-caps-detect.js
@@ -57,8 +57,16 @@ export async function getAvailableVREntryTypes() {
   const isDaydreamCapableBrowser = !!(isWebVRCapableBrowser && browser.name === "chrome" && !isSamsungBrowser);
   const isIDevice = ["iPhone", "iPad", "iPod"].indexOf(deviceDetect.device) > -1;
   const isFirefoxBrowser = browser.name === "firefox";
+  const isUIWebView = typeof navigator.mediaDevices === "undefined";
+
+  const safari = isIDevice
+    ? !isUIWebView ? VR_DEVICE_AVAILABILITY.yes : VR_DEVICE_AVAILABILITY.maybe
+    : VR_DEVICE_AVAILABILITY.no;
+
+  const screen = isInHMD
+    ? VR_DEVICE_AVAILABILITY.no
+    : isIDevice && isUIWebView ? VR_DEVICE_AVAILABILITY.maybe : VR_DEVICE_AVAILABILITY.yes;
 
-  const screen = isInHMD ? VR_DEVICE_AVAILABILITY.no : VR_DEVICE_AVAILABILITY.yes;
   let generic = mobiledetect.mobile() ? VR_DEVICE_AVAILABILITY.no : VR_DEVICE_AVAILABILITY.maybe;
   let cardboard = VR_DEVICE_AVAILABILITY.no;
 
@@ -107,5 +115,5 @@ export async function getAvailableVREntryTypes() {
     }
   }
 
-  return { screen, generic, gearvr, daydream, cardboard, isInHMD };
+  return { screen, generic, gearvr, daydream, cardboard, isInHMD, safari };
 }
diff --git a/src/utils/webgl.js b/src/utils/webgl.js
index 17b608b340d182da448ec549baafa2a90c838a45..503f96f431dce2b9ad3dfd0894d96ab90ebfd5be 100644
--- a/src/utils/webgl.js
+++ b/src/utils/webgl.js
@@ -16,20 +16,21 @@ function checkFloatTextureSupport() {
   renderer.dispose();
   return result;
 }
-const supportsFloatTextures = checkFloatTextureSupport();
 
 export function patchWebGLRenderingContext() {
-  const originalGetExtension = WebGLRenderingContext.prototype.getExtension;
-  function patchedGetExtension(name) {
+  if (/Android.+Firefox/.test(navigator.userAgent)) {
     // It appears that Galaxy S6 devices falsely report that they support
     // OES_texture_float in Firefox. This workaround disables float textures
     // for those devices.
     // See https://github.com/mozilla/hubs/issues/32 and
     // https://bugzilla.mozilla.org/show_bug.cgi?id=1338656
-    if (name === "OES_texture_float" && /Android.+Firefox/.test(navigator.userAgent) && !supportsFloatTextures) {
-      return null;
-    }
-    return originalGetExtension.call(this, name);
+    const originalGetExtension = WebGLRenderingContext.prototype.getExtension;
+    const supportsFloatTextures = checkFloatTextureSupport();
+    WebGLRenderingContext.prototype.getExtension = function patchedGetExtension(name) {
+      if (name === "OES_texture_float" && !supportsFloatTextures) {
+        return null;
+      }
+      return originalGetExtension.call(this, name);
+    };
   }
-  WebGLRenderingContext.prototype.getExtension = patchedGetExtension;
 }
diff --git a/webpack.config.js b/webpack.config.js
index 17fbb4f8b666a03a7d1577f07d011040b6125f40..6f66a85c9c59fb1cbd5a339519af6eec23987eb7 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -41,6 +41,10 @@ function createHTTPSConfig() {
               {
                 type: 2,
                 value: "localhost"
+              },
+              {
+                type: 2,
+                value: "hubs.local"
               }
             ]
           }
@@ -93,6 +97,7 @@ const config = {
     https: createHTTPSConfig(),
     host: "0.0.0.0",
     useLocalIp: true,
+    public: "hubs.local:8080",
     port: 8080,
     before: function(app) {
       // networked-aframe makes HEAD requests to the server for time syncing. Respond with an empty body.
diff --git a/yarn.lock b/yarn.lock
index 906c4615fdbd1f5f97e331043fbf27350f45799d..589589dc64406716bd1084f40581f5ad2b410af0 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1643,6 +1643,10 @@ buffer@^5.0.2:
     base64-js "^1.0.2"
     ieee754 "^1.1.4"
 
+buffered-interpolation@^0.2.3:
+  version "0.2.3"
+  resolved "https://registry.yarnpkg.com/buffered-interpolation/-/buffered-interpolation-0.2.3.tgz#6e723d44c4f4aa76704fc470654174e279591c31"
+
 builtin-modules@^1.0.0:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f"
@@ -2036,8 +2040,8 @@ colormin@^1.0.5:
     has "^1.0.1"
 
 colors@*:
-  version "1.2.5"
-  resolved "https://registry.yarnpkg.com/colors/-/colors-1.2.5.tgz#89c7ad9a374bc030df8013241f68136ed8835afc"
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/colors/-/colors-1.3.0.tgz#5f20c9fef6945cb1134260aab33bfbdc8295e04e"
 
 colors@1.0.3:
   version "1.0.3"
@@ -5511,8 +5515,9 @@ neo-async@^2.5.0:
 
 "networked-aframe@https://github.com/mozillareality/networked-aframe#mr-social-client/master":
   version "0.6.1"
-  resolved "https://github.com/mozillareality/networked-aframe#424b41cfdf53db64033885da411c33685644db97"
+  resolved "https://github.com/mozillareality/networked-aframe#7b88e49e855b60e376886abe23ea311b27acdffe"
   dependencies:
+    buffered-interpolation "^0.2.3"
     easyrtc "1.1.0"
     express "^4.10.7"
     serve-static "^1.8.0"