diff --git a/.eslintignore b/.eslintignore
new file mode 100644
index 0000000000000000000000000000000000000000..70fa045e816539292b653dad3df26c6f68b99be0
--- /dev/null
+++ b/.eslintignore
@@ -0,0 +1,2 @@
+!.eslintrc.js
+src/vendor/*
diff --git a/.eslintrc.js b/.eslintrc.js
new file mode 100644
index 0000000000000000000000000000000000000000..1cadfd103fd1910f79fb0e7928b0663b9e6a248a
--- /dev/null
+++ b/.eslintrc.js
@@ -0,0 +1,23 @@
+module.exports = {
+  parser: "babel-eslint",
+  env: {
+    browser: true,
+    es6: true,
+    node: true
+  },
+  globals: {
+    THREE: true,
+    AFRAME: true,
+    NAF: true
+  },
+  plugins: ["prettier", "react"],
+  rules: {
+    "prettier/prettier": "error",
+    "prefer-const": "error",
+    "no-var": "error",
+    "no-throw-literal": "error",
+    // Light console usage is useful but remove debug logs before merging to master.
+    "no-console": "off"
+  },
+  extends: ["prettier", "plugin:react/recommended", "eslint:recommended"]
+};
diff --git a/.eslintrc.json b/.eslintrc.json
deleted file mode 100644
index 8ab63d8cc0f6d8854b53e96c03a7889080ecb650..0000000000000000000000000000000000000000
--- a/.eslintrc.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
-  "parser": "babel-eslint",
-  "plugins": [
-    "prettier",
-    "react"
-  ],
-  "rules": {
-    "prettier/prettier": "error",
-    "prefer-const": "error",
-    "no-var": "error"
-  },
-  "extends": [
-    "prettier",
-    "plugin:react/recommended"
-  ]
-}
diff --git a/.prettierignore b/.prettierignore
index edded1b3e9cda6cef1bf33b9c8e08aa12f1f84a7..4bd6eed9e8bcf23ca49b895c57f0ba6d6290e14b 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,2 +1,3 @@
 package.json
 *.gltf
+src/vendor/
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000000000000000000000000000000000000..73a40d0761a7b442cd2435b7ac37a59f5c13a875
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,8 @@
+language: node_js
+node_js: node
+cache: yarn
+before_install:
+  - curl -o- -L https://yarnpkg.com/install.sh | bash -s -- --version 1.5.1
+  - export PATH="$HOME/.yarn/bin:$PATH"
+install: yarn 
+script: yarn lint
diff --git a/package.json b/package.json
index 5baab2abb42b937e578c0b379d96fcc81f9f9e39..93154d2d912d0d40d3ff8a067326064c206d46b5 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,8 @@
     "postinstall": "node ./scripts/postinstall.js",
     "start": "cross-env NODE_ENV=development webpack-dev-server",
     "build": "rimraf ./public && cross-env NODE_ENV=production webpack --mode=production",
-    "prettier": "prettier --write src/**/*.js"
+    "prettier": "prettier --write '*.js' 'src/**/*.js'",
+    "lint": "eslint '*.js' 'src/**/*.js'"
   },
   "dependencies": {
     "@fortawesome/fontawesome": "^1.1.5",
@@ -63,7 +64,7 @@
     "extract-text-webpack-plugin": "4.0.0-alpha.0",
     "file-loader": "^1.1.10",
     "html-loader": "^0.5.5",
-    "html-webpack-plugin": "webpack-contrib/html-webpack-plugin",
+    "html-webpack-plugin": "^3.1.0",
     "lodash": "^4.17.5",
     "node-sass": "^4.7.2",
     "prettier": "^1.7.0",
diff --git a/src/activators/pressedmove.js b/src/activators/pressedmove.js
index c035383186e0997908be2eae5868a1a97e546dd4..fb7d7470723246b687049eafeb83d51e84f0e8bd 100644
--- a/src/activators/pressedmove.js
+++ b/src/activators/pressedmove.js
@@ -18,10 +18,10 @@ PressedMove.prototype = {
       this.onActivate(event);
     }
   },
-  onButtonDown: function(event) {
+  onButtonDown: function() {
     this.pressed = true;
   },
-  onButtonUp: function(event) {
+  onButtonUp: function() {
     this.pressed = false;
   },
 
diff --git a/src/activators/shortpress.js b/src/activators/shortpress.js
index 7f47450bef78161554a89569d4a610d7f7691877..fd9b8da0a212dc6acfba55509072d9c46c079305 100644
--- a/src/activators/shortpress.js
+++ b/src/activators/shortpress.js
@@ -16,13 +16,12 @@ function ShortPress(el, button, onActivate) {
 
 ShortPress.prototype = {
   onButtonDown(event) {
-    var self = this;
-    this.pressTimer = window.setTimeout(function() {
-      self.onActivate(event);
+    this.pressTimer = window.setTimeout(() => {
+      this.onActivate(event);
     }, this.timeOut);
   },
 
-  onButtonUp(event) {
+  onButtonUp() {
     clearTimeout(this.pressTimer);
   },
 
diff --git a/src/assets/avatars/avatars.js b/src/assets/avatars/avatars.js
index f7a5500048710c5bf0910e5bc781000764871312..7934ecb66c1439b04e892367dc3655d1f3826abe 100644
--- a/src/assets/avatars/avatars.js
+++ b/src/assets/avatars/avatars.js
@@ -1,65 +1,65 @@
 export const avatars = [
   {
-    "id": "botdefault",
-    "models": {
-      "low": `${ require("./BotDefault_Avatar_Unlit.glb") }`,
-      "high": `${ require("./BotDefault_Avatar.glb") }`
+    id: "botdefault",
+    models: {
+      low: `${require("./BotDefault_Avatar_Unlit.glb")}`,
+      high: `${require("./BotDefault_Avatar.glb")}`
     }
   },
   {
-    "id": "botbobo",
-    "models": {
-      "low": `${ require("./BotBobo_Avatar_Unlit.glb") }`,
-      "high": `${ require("./BotBobo_Avatar.glb") }`
+    id: "botbobo",
+    models: {
+      low: `${require("./BotBobo_Avatar_Unlit.glb")}`,
+      high: `${require("./BotBobo_Avatar.glb")}`
     }
   },
   {
-    "id": "botdom",
-    "models": {
-      "low": `${ require("./BotDom_Avatar_Unlit.glb") }`,
-      "high": `${ require("./BotDom_Avatar.glb") }`
+    id: "botdom",
+    models: {
+      low: `${require("./BotDom_Avatar_Unlit.glb")}`,
+      high: `${require("./BotDom_Avatar.glb")}`
     }
   },
   {
-    "id": "botgreg",
-    "models": {
-      "low": `${ require("./BotGreg_Avatar_Unlit.glb") }`,
-      "high": `${ require("./BotGreg_Avatar.glb") }`
+    id: "botgreg",
+    models: {
+      low: `${require("./BotGreg_Avatar_Unlit.glb")}`,
+      high: `${require("./BotGreg_Avatar.glb")}`
     }
   },
   {
-    "id": "botguest",
-    "models": {
-      "low": `${ require("./BotGuest_Avatar_Unlit.glb") }`,
-      "high": `${ require("./BotGuest_Avatar.glb") }`
+    id: "botguest",
+    models: {
+      low: `${require("./BotGuest_Avatar_Unlit.glb")}`,
+      high: `${require("./BotGuest_Avatar.glb")}`
     }
   },
   {
-    "id": "botjim",
-    "models": {
-      "low": `${ require("./BotJim_Avatar_Unlit.glb") }`,
-      "high": `${ require("./BotJim_Avatar.glb") }`
+    id: "botjim",
+    models: {
+      low: `${require("./BotJim_Avatar_Unlit.glb")}`,
+      high: `${require("./BotJim_Avatar.glb")}`
     }
   },
   {
-    "id": "botpinky",
-    "models": {
-      "low": `${ require("./BotPinky_Avatar_Unlit.glb") }`,
-      "high": `${ require("./BotPinky_Avatar.glb") }`
+    id: "botpinky",
+    models: {
+      low: `${require("./BotPinky_Avatar_Unlit.glb")}`,
+      high: `${require("./BotPinky_Avatar.glb")}`
     }
   },
   {
-    "id": "botrobert",
-    "models": {
-      "low": `${ require("./BotRobert_Avatar_Unlit.glb") }`,
-      "high": `${ require("./BotRobert_Avatar.glb") }`
+    id: "botrobert",
+    models: {
+      low: `${require("./BotRobert_Avatar_Unlit.glb")}`,
+      high: `${require("./BotRobert_Avatar.glb")}`
     }
   },
   {
-    "id": "botwoody",
-    "models": {
-      "low": `${ require("./BotWoody_Avatar_Unlit.glb") }`,
-      "high": `${ require("./BotWoody_Avatar.glb") }`
+    id: "botwoody",
+    models: {
+      low: `${require("./BotWoody_Avatar_Unlit.glb")}`,
+      high: `${require("./BotWoody_Avatar.glb")}`
     }
   }
 ];
diff --git a/src/react-components/2d-hud.css b/src/assets/stylesheets/2d-hud.css
similarity index 79%
rename from src/react-components/2d-hud.css
rename to src/assets/stylesheets/2d-hud.css
index 556431ac6ca824e0bbcf4cf4d5644e05be4dc309..a8324a5bb8b054c4da44f82aadfe96647c5cb45b 100644
--- a/src/react-components/2d-hud.css
+++ b/src/assets/stylesheets/2d-hud.css
@@ -30,14 +30,16 @@
   width: 48px;
   height: 48px;
   background-size: 100%;
-  background-image: url(../assets/hud/avatar.jpg);
+  background-image: url(../hud/avatar.jpg);
 }
 
 :local(.mic) {
   display: flex;
   width: 48px;
   height: 48px;
-  mask: url(../assets/hud/unmuted.png);
+  -webkit-mask: url(../hud/unmuted.png);
+  -webkit-mask-size: 48px;
+  mask: url(../hud/unmuted.png);
   mask-size: 48px;
   background-color: white;
   cursor: pointer;
@@ -56,7 +58,9 @@
 }
 
 :local(.mic.muted) {
-  mask: url(../assets/hud/muted.png);
+  -webkit-mask: url(../hud/muted.png);
+  -webkit-mask-size: 48px;
+  mask: url(../hud/muted.png);
   mask-size: 48px;
 }
 
diff --git a/src/avatar-selector.js b/src/avatar-selector.js
index 6acfe154caebb1c9f799631940aeb918e945fd7e..ea3ff71a0f1f225cb9ae9f68a813167125bc3207 100644
--- a/src/avatar-selector.js
+++ b/src/avatar-selector.js
@@ -1,7 +1,7 @@
 import ReactDOM from "react-dom";
 import React from "react";
 import queryString from "query-string";
-import { IntlProvider, FormattedMessage, addLocaleData } from "react-intl";
+import { IntlProvider, addLocaleData } from "react-intl";
 import en from "react-intl/locale-data/en";
 
 import "./assets/stylesheets/avatar-selector.scss";
@@ -12,13 +12,15 @@ import "./components/audio-feedback";
 import "./components/loop-animation";
 import "./elements/a-progressive-asset";
 import "./gltf-component-mappings";
-import { avatars } from "./assets/avatars/avatars.js";
-import { avatarIds } from "./utils/identity";
+import { avatars } from "./assets/avatars/avatars";
 
+import registerTelemetry from "./telemetry";
 import { App } from "./App";
 import AvatarSelector from "./react-components/avatar-selector";
 import localeData from "./assets/translations.data.json";
 
+registerTelemetry();
+
 window.APP = new App();
 const hash = queryString.parse(location.hash);
 const isMobile = AFRAME.utils.device.isMobile();
diff --git a/src/behaviours/oculus-touch-joystick-dpad4.js b/src/behaviours/oculus-touch-joystick-dpad4.js
index 758fb603c70faceb932539d722e08976e603046d..bf397ba2fbda2357c7fdf1cfc0310c8aa1ba548c 100644
--- a/src/behaviours/oculus-touch-joystick-dpad4.js
+++ b/src/behaviours/oculus-touch-joystick-dpad4.js
@@ -1,4 +1,4 @@
-import { angleTo4Direction, angleTo8Direction } from "../utils/dpad";
+import { angleTo4Direction } from "../utils/dpad";
 
 // @TODO specify 4 or 8 direction
 function oculus_touch_joystick_dpad4(el, outputPrefix) {
@@ -15,11 +15,8 @@ oculus_touch_joystick_dpad4.prototype = {
   emitDPad4: function(event) {
     const x = event.detail.axis[0];
     const y = event.detail.axis[1];
-    const inCenter =
-      Math.abs(x) < this.centerRadius && Math.abs(y) < this.centerRadius;
-    const current = inCenter
-      ? "center"
-      : this.angleToDirection(Math.atan2(x, -y));
+    const inCenter = Math.abs(x) < this.centerRadius && Math.abs(y) < this.centerRadius;
+    const current = inCenter ? "center" : this.angleToDirection(Math.atan2(x, -y));
     if (current !== this.previous) {
       this.previous = current;
       event.target.emit(`${this.outputPrefix}_dpad4_${current}`);
diff --git a/src/behaviours/vive-trackpad-dpad4.js b/src/behaviours/vive-trackpad-dpad4.js
index 3f4e3a275a4c6bad6a74aa6ef3c8836bb3a26500..99bdf8873d4cbcce3541691e6073af71c153db2b 100644
--- a/src/behaviours/vive-trackpad-dpad4.js
+++ b/src/behaviours/vive-trackpad-dpad4.js
@@ -1,4 +1,4 @@
-import { angleTo4Direction, angleTo8Direction } from "../utils/dpad";
+import { angleTo4Direction } from "../utils/dpad";
 
 function vive_trackpad_dpad4(el, outputPrefix) {
   this.outputPrefix = outputPrefix;
@@ -16,20 +16,17 @@ function vive_trackpad_dpad4(el, outputPrefix) {
 }
 
 vive_trackpad_dpad4.prototype = {
-  press: function(_) {
+  press: function() {
     this.pressed = true;
   },
-  unpress: function(_) {
+  unpress: function() {
     this.pressed = false;
   },
   emitDPad4: function(event) {
     const x = event.detail.axis[0];
     const y = event.detail.axis[1];
-    const inCenter =
-      Math.abs(x) < this.centerRadius && Math.abs(y) < this.centerRadius;
-    const direction = inCenter
-      ? "center"
-      : angleTo4Direction(Math.atan2(x, -y));
+    const inCenter = Math.abs(x) < this.centerRadius && Math.abs(y) < this.centerRadius;
+    const direction = inCenter ? "center" : angleTo4Direction(Math.atan2(x, -y));
     const pressed = this.pressed ? "pressed_" : "";
     const current = `${pressed + direction}`; // e.g. "pressed_north"
 
diff --git a/src/components/bone-mute-state-indicator.js b/src/components/bone-mute-state-indicator.js
index 7c6dc5913e971a3a3981f2d8af7ed3e151dcf0e8..2d79b3f9c37feee028b89afbe6eaffe7bbd654d8 100644
--- a/src/components/bone-mute-state-indicator.js
+++ b/src/components/bone-mute-state-indicator.js
@@ -12,12 +12,8 @@ AFRAME.registerComponent("bone-mute-state-indicator", {
   init() {
     this.onStateToggled = this.onStateToggled.bind(this);
     this.el.addEventListener("model-loaded", () => {
-      this.unmutedBone = this.el.object3D.getObjectByName(
-        this.data.unmutedBoneName
-      );
-      this.mutedBone = this.el.object3D.getObjectByName(
-        this.data.mutedBoneName
-      );
+      this.unmutedBone = this.el.object3D.getObjectByName(this.data.unmutedBoneName);
+      this.mutedBone = this.el.object3D.getObjectByName(this.data.mutedBoneName);
       console.log(this.unmutedBone, this.mutedBone);
       this.modelLoaded = true;
 
diff --git a/src/components/character-controller.js b/src/components/character-controller.js
index 20553d36ce9c06ca84806fd5b6adc7350ddf2aaa..1249dff3b0fbb3231bb52bd373e6cd91b7f3a544 100644
--- a/src/components/character-controller.js
+++ b/src/components/character-controller.js
@@ -60,11 +60,11 @@ AFRAME.registerComponent("character-controller", {
     this.angularVelocity = event.detail.value;
   },
 
-  snapRotateLeft: function(event) {
+  snapRotateLeft: function() {
     this.pendingSnapRotationMatrix.copy(this.leftRotationMatrix);
   },
 
-  snapRotateRight: function(event) {
+  snapRotateRight: function() {
     this.pendingSnapRotationMatrix.copy(this.rightRotationMatrix);
   },
 
@@ -79,9 +79,6 @@ AFRAME.registerComponent("character-controller", {
     const rotationInvMatrix = new THREE.Matrix4();
     const pivotRotationMatrix = new THREE.Matrix4();
     const pivotRotationInvMatrix = new THREE.Matrix4();
-    const position = new THREE.Vector3();
-    const currentPosition = new THREE.Vector3();
-    const movementVector = new THREE.Vector3();
 
     return function(t, dt) {
       const deltaSeconds = dt / 1000;
diff --git a/src/components/debug.js b/src/components/debug.js
index 77202f03295be677b9156e1ff16f11100ab19351..6448bf236966876e05069f96192153cf0f8400b6 100644
--- a/src/components/debug.js
+++ b/src/components/debug.js
@@ -1,25 +1,30 @@
 AFRAME.registerComponent("lifecycle-checker", {
   schema: {
+    name: { type: "string" },
     tick: { default: false }
   },
   init: function() {
-    console.log("init", this.el);
+    this.log("init");
   },
   update: function() {
-    console.log("update", this.el);
+    this.log("update");
   },
   tick: function() {
     if (this.data.tick) {
-      console.log("tick", this.el);
+      this.log("tick");
     }
   },
   remove: function() {
-    console.log("remove", this.el);
+    this.log("remove");
   },
   pause: function() {
-    console.log("pause", this.el);
+    this.log("pause");
   },
   play: function() {
-    console.log("play", this.el);
+    this.log("play");
+  },
+
+  log: function(method) {
+    console.info(`lifecycle-checker:${this.data.name} ${method}`);
   }
 });
diff --git a/src/components/event-repeater.js b/src/components/event-repeater.js
index f099038b48ca692f1e7443e650b59cdfaeb7481e..64e28720698d8aaa26a8f0ce1525301cebd1ee2f 100644
--- a/src/components/event-repeater.js
+++ b/src/components/event-repeater.js
@@ -26,5 +26,4 @@ AFRAME.registerComponent("event-repeater", {
   _handleEvent: function(event, e) {
     this.el.emit(event, e.details);
   }
-
 });
diff --git a/src/components/gltf-bundle.js b/src/components/gltf-bundle.js
index 791fa7eef9076b44eda996192181dc488e755846..85238043fb2319dd1a6e55bc953186890fd2e721 100644
--- a/src/components/gltf-bundle.js
+++ b/src/components/gltf-bundle.js
@@ -17,8 +17,8 @@ AFRAME.registerComponent("gltf-bundle", {
     for (let i = 0; i < bundleJson.assets.length; i++) {
       const asset = bundleJson.assets[i];
       const src = asset.src;
-      const gltfEl = document.createElement("a-gltf-entity");
-      gltfEl.setAttribute("src", src);
+      const gltfEl = document.createElement("a-entity");
+      gltfEl.setAttribute("gltf-model-plus", { src });
       gltfEl.setAttribute("position", "0 0 0");
       loaded.push(new Promise(resolve => gltfEl.addEventListener("model-loaded", resolve)));
       this.el.appendChild(gltfEl);
diff --git a/src/elements/a-gltf-entity.js b/src/components/gltf-model-plus.js
similarity index 56%
rename from src/elements/a-gltf-entity.js
rename to src/components/gltf-model-plus.js
index 33489e2bc1792aa2b9dd4bddca360caaa39c509b..f1993414fc6ec0029e5b918d452c7a009ab6c3fd 100644
--- a/src/elements/a-gltf-entity.js
+++ b/src/components/gltf-model-plus.js
@@ -1,6 +1,6 @@
 const GLTFCache = {};
 
-AFRAME.AGLTFEntity = {
+AFRAME.GLTFModelPlus = {
   defaultInflator(el, componentName, componentData) {
     if (!AFRAME.components[componentName]) {
       throw new Error(`Inflator failed. "${componentName}" component does not exist.`);
@@ -14,8 +14,8 @@ AFRAME.AGLTFEntity = {
     }
   },
   registerComponent(componentKey, componentName, inflator) {
-    AFRAME.AGLTFEntity.components[componentKey] = {
-      inflator: inflator || AFRAME.AGLTFEntity.defaultInflator,
+    AFRAME.GLTFModelPlus.components[componentKey] = {
+      inflator: inflator || AFRAME.GLTFModelPlus.defaultInflator,
       componentName
     };
   },
@@ -128,7 +128,7 @@ const inflateEntities = function(parentEl, node) {
   if (entityComponents) {
     for (const prop in entityComponents) {
       if (entityComponents.hasOwnProperty(prop)) {
-        const { inflator, componentName } = AFRAME.AGLTFEntity.components[prop];
+        const { inflator, componentName } = AFRAME.GLTFModelPlus.components[prop];
 
         if (inflator) {
           inflator(el, componentName, entityComponents[prop]);
@@ -190,138 +190,94 @@ function cachedLoadGLTF(src, onProgress) {
   });
 }
 
-AFRAME.registerElement("a-gltf-entity", {
-  prototype: Object.create(AFRAME.AEntity.prototype, {
-    load: {
-      async value() {
-        if (this.hasLoaded || !this.parentEl) {
-          return;
-        }
-
-        // The code above and below this are from AEntity.prototype.load, we need to monkeypatch in gltf loading mid function
-        this.loadTemplates();
-        await this.applySrc(this.getAttribute("src"));
-        //
-
-        AFRAME.ANode.prototype.load.call(this, () => {
-          // Check if entity was detached while it was waiting to load.
-          if (!this.parentEl) {
-            return;
-          }
+AFRAME.registerComponent("gltf-model-plus", {
+  schema: {
+    src: { type: "string" },
+    inflate: { default: false }
+  },
 
-          this.updateComponents();
-          if (this.isScene || this.parentEl.isPlaying) {
-            this.play();
-          }
-        });
-      }
-    },
-
-    loadTemplates: {
-      value() {
-        this.templates = [];
-        this.querySelectorAll(":scope > template").forEach(templateEl =>
-          this.templates.push({
-            selector: templateEl.getAttribute("data-selector"),
-            templateRoot: document.importNode(
-              templateEl.firstElementChild || templateEl.content.firstElementChild,
-              true
-            )
-          })
-        );
-      }
-    },
-
-    applySrc: {
-      async value(src) {
-        try {
-          // If the src attribute is a selector, get the url from the asset item.
-          if (src && src.charAt(0) === "#") {
-            const assetEl = document.getElementById(src.substring(1));
-            if (!assetEl) { 
-              console.warn(`Attempted to use non-existent asset ${src} as src for`, this);
-              return;
-            }
-
-            const fallbackSrc = assetEl.getAttribute("src");
-            const highSrc = assetEl.getAttribute("high-src");
-            const lowSrc = assetEl.getAttribute("low-src");
-
-            if (highSrc && window.APP.quality === "high") {
-              src = highSrc;
-            } else if (lowSrc && window.APP.quality === "low") {
-              src = lowSrc;
-            } else {
-              src = fallbackSrc;
-            }
-          }
+  init() {
+    this.loadTemplates();
+  },
 
-          if (src === this.lastSrc) return;
-          this.lastSrc = src;
+  update() {
+    this.applySrc(this.data.src);
+  },
 
-          if (!src) {
-            if (this.inflatedEl) {
-              console.warn("gltf-entity set to an empty source, unloading inflated model.");
-              this.removeInflatedEl();
-            }
-            return;
-          }
+  loadTemplates() {
+    this.templates = [];
+    this.el.querySelectorAll(":scope > template").forEach(templateEl =>
+      this.templates.push({
+        selector: templateEl.getAttribute("data-selector"),
+        templateRoot: document.importNode(templateEl.firstElementChild || templateEl.content.firstElementChild, true)
+      })
+    );
+  },
 
-          const model = await cachedLoadGLTF(src);
+  async applySrc(src) {
+    try {
+      // If the src attribute is a selector, get the url from the asset item.
+      if (src && src.charAt(0) === "#") {
+        const assetEl = document.getElementById(src.substring(1));
+
+        const fallbackSrc = assetEl.getAttribute("src");
+        const highSrc = assetEl.getAttribute("high-src");
+        const lowSrc = assetEl.getAttribute("low-src");
+
+        if (highSrc && window.APP.quality === "high") {
+          src = highSrc;
+        } else if (lowSrc && window.APP.quality === "low") {
+          src = lowSrc;
+        } else {
+          src = fallbackSrc;
+        }
+      }
 
-          // If we started loading something else already
-          // TODO: there should be a way to cancel loading instead
-          if (src != this.lastSrc) return;
+      if (src === this.lastSrc) return;
+      this.lastSrc = src;
 
-          // If we had inflated something already before, clean that up
+      if (!src) {
+        if (this.inflatedEl) {
+          console.warn("gltf-model-plus set to an empty source, unloading inflated model.");
           this.removeInflatedEl();
+        }
+        return;
+      }
 
-          this.model = model.scene || model.scenes[0];
-          this.model.animations = model.animations;
+      const model = await cachedLoadGLTF(src);
 
-          this.setObject3D("mesh", this.model);
+      // If we started loading something else already
+      // TODO: there should be a way to cancel loading instead
+      if (src != this.lastSrc) return;
 
-          if (this.getAttribute("inflate")) {
-            this.inflatedEl = inflateEntities(this, this.model);
-            // TODO: Still don't fully understand the lifecycle here and how it differs between browsers, we should dig in more
-            // Wait one tick for the appended custom elements to be connected before attaching templates
-            await nextTick();
-            if (src != this.lastSrc) return; // TODO: there must be a nicer pattern for this
-            this.templates.forEach(attachTemplate.bind(null, this));
-          }
+      // If we had inflated something already before, clean that up
+      this.removeInflatedEl();
 
-          this.emit("model-loaded", { format: "gltf", model: this.model });
-        } catch (e) {
-          console.error("Failed to load glTF model", e.message, this);
-          this.emit("model-error", { format: "gltf", src });
-        }
-      }
-    },
+      this.model = model.scene || model.scenes[0];
+      this.model.animations = model.animations;
 
-    removeInflatedEl: {
-      value() {
-        if (this.inflatedEl) {
-          this.inflatedEl.parentNode.removeChild(this.inflatedEl);
-          delete this.inflatedEl;
-        }
-      }
-    },
+      this.el.setObject3D("mesh", this.model);
 
-    attributeChangedCallback: {
-      value(attr, oldVal, newVal) {
-        if (attr === "src") {
-          this.applySrc(newVal);
-        }
+      if (this.data.inflate) {
+        this.inflatedEl = inflateEntities(this.el, this.model);
+        // TODO: Still don't fully understand the lifecycle here and how it differs between browsers, we should dig in more
+        // Wait one tick for the appended custom elements to be connected before attaching templates
+        await nextTick();
+        if (src != this.lastSrc) return; // TODO: there must be a nicer pattern for this
+        this.templates.forEach(attachTemplate.bind(null, this.el));
       }
-    },
 
-    setAttribute: {
-      value(attr, arg1, arg2) {
-        if (attr === "src") {
-          this.applySrc(arg1);
-        }
-        AFRAME.AEntity.prototype.setAttribute.call(this, attr, arg1, arg2);
-      }
+      this.el.emit("model-loaded", { format: "gltf", model: this.model });
+    } catch (e) {
+      console.error("Failed to load glTF model", e.message, this);
+      this.emit("model-error", { format: "gltf", src });
     }
-  })
+  },
+
+  removeInflatedEl() {
+    if (this.inflatedEl) {
+      this.inflatedEl.parentNode.removeChild(this.inflatedEl);
+      delete this.inflatedEl;
+    }
+  }
 });
diff --git a/src/components/haptic-feedback.js b/src/components/haptic-feedback.js
index 1ef413d38939a183c25438694cebaec934d9cf23..35a79cd9555e2864e5c496f671fcb9ddd8831345 100644
--- a/src/components/haptic-feedback.js
+++ b/src/components/haptic-feedback.js
@@ -18,9 +18,9 @@ AFRAME.registerComponent("haptic-feedback", {
   },
 
   getActuator() {
-    return new Promise((resolve, reject) => {
+    return new Promise(resolve => {
       const tryGetActivator = () => {
-        var trackedControls = this.el.components["tracked-controls"];
+        const trackedControls = this.el.components["tracked-controls"];
         if (
           trackedControls &&
           trackedControls.controller &&
@@ -44,7 +44,7 @@ AFRAME.registerComponent("haptic-feedback", {
   },
 
   pulse: function(event) {
-    let { intensity } = event.detail;
+    const { intensity } = event.detail;
     if (!strengthForIntensity[intensity]) {
       console.warn(`Invalid intensity : ${intensity}`);
       return;
diff --git a/src/components/ik-controller.js b/src/components/ik-controller.js
index 0a1990bd8ede46f4e5bbdde5cfced182ed4f5a3d..4fd89465e1d4ce97c24276b5d23d89eda5a25163 100644
--- a/src/components/ik-controller.js
+++ b/src/components/ik-controller.js
@@ -142,7 +142,6 @@ AFRAME.registerComponent("ik-controller", {
       cameraYRotation,
       cameraYQuaternion,
       invHipsQuaternion,
-      headQuaternion,
       leftHand,
       rightHand,
       rootToChest,
diff --git a/src/components/in-world-hud.js b/src/components/in-world-hud.js
index e69cfd7374eaa1ca1972b3cb00784d837dc987b8..2c10aad240d6ee2d21ea8d536751b0fc3785a463 100644
--- a/src/components/in-world-hud.js
+++ b/src/components/in-world-hud.js
@@ -96,7 +96,7 @@ AFRAME.registerComponent("in-world-hud", {
     this.el.sceneEl.removeEventListener("micAudio", this.onAudioFrequencyChange);
   },
 
-  tick: function(t, dt) {
+  tick: function() {
     if (!this.analyser) return;
 
     this.analyser.getByteFrequencyData(this.levels);
diff --git a/src/components/mute-mic.js b/src/components/mute-mic.js
index 272b40e3b41f5d4aa49d7107b6aa41785b8d5596..00729ba1904002a88729e21ed82f69dbb872dda9 100644
--- a/src/components/mute-mic.js
+++ b/src/components/mute-mic.js
@@ -1,6 +1,6 @@
 const bindAllEvents = function(elements, events, f) {
   if (!elements || !elements.length) return;
-  for (var el of elements) {
+  for (const el of elements) {
     events.length &&
       events.forEach(e => {
         el.addEventListener(e, f);
@@ -9,7 +9,7 @@ const bindAllEvents = function(elements, events, f) {
 };
 const unbindAllEvents = function(elements, events, f) {
   if (!elements || !elements.length) return;
-  for (var el of elements) {
+  for (const el of elements) {
     events.length &&
       events.forEach(e => {
         el.removeEventListener(e, f);
diff --git a/src/components/networked-counter.js b/src/components/networked-counter.js
index c35dcbaa461fa5ca8627d7cf693455d4244ec9fe..9c4fb7105578b49b48d5abe61e4c92193a0aaede 100644
--- a/src/components/networked-counter.js
+++ b/src/components/networked-counter.js
@@ -20,7 +20,7 @@ AFRAME.registerComponent("networked-counter", {
         item.el.removeEventListener(this.data.release_event, item.onReleaseHandler);
       }
     }
-    
+
     for (const id in this.timeouts) {
       this._removeTimeout(id);
     }
@@ -75,11 +75,11 @@ AFRAME.registerComponent("networked-counter", {
     }
   },
 
-  _onGrabbed: function(id, e) {
+  _onGrabbed: function(id) {
     this._removeTimeout(id);
   },
 
-  _onReleased: function(id, e) {
+  _onReleased: function(id) {
     this._removeTimeout(id);
     this._addTimeout(id);
     this.queue[id].ts = Date.now();
@@ -91,7 +91,6 @@ AFRAME.registerComponent("networked-counter", {
         ts = Number.MAX_VALUE;
       for (const id in this.queue) {
         if (this.queue.hasOwnProperty(id)) {
-          const expiration = this.queue[id].ts + this.data.ttl * 1000;
           if (this.queue[id].ts < ts && !this._isCurrentlyGrabbed(id)) {
             oldest = this.queue[id];
             ts = this.queue[id].ts;
diff --git a/src/components/networked-video-player.js b/src/components/networked-video-player.js
index 51adcf2f7f2ddd6d0023c54442e03627ea952c70..189a43e393f359a5c2d217b5978132476f6407c6 100644
--- a/src/components/networked-video-player.js
+++ b/src/components/networked-video-player.js
@@ -25,7 +25,7 @@ AFRAME.registerComponent("networked-video-player", {
     if (ownerId !== NAF.clientId && rejectScreenShares) {
       // Toggle material visibility since object visibility is network-synced
       // TODO: There ought to be a better way to disable network syncs on a remote entity
-      this.el.setAttribute("material", {visible: false});
+      this.el.setAttribute("material", { visible: false });
       return;
     }
 
diff --git a/src/components/offset-relative-to.js b/src/components/offset-relative-to.js
index 923eae3e5673488e8eceb726b9e91f592285f546..f940b687284b34677c579e931557fc9671631f41 100644
--- a/src/components/offset-relative-to.js
+++ b/src/components/offset-relative-to.js
@@ -12,10 +12,7 @@ AFRAME.registerComponent("offset-relative-to", {
   },
   init() {
     this.updateOffset();
-    this.el.sceneEl.addEventListener(
-      this.data.on,
-      this.updateOffset.bind(this)
-    );
+    this.el.sceneEl.addEventListener(this.data.on, this.updateOffset.bind(this));
   },
   updateOffset() {
     const offsetVector = new THREE.Vector3().copy(this.data.offset);
diff --git a/src/components/player-info.js b/src/components/player-info.js
index ac2fc6b55fcdaf0215e26327368c07dd67e88fcc..b9455352a104bd71aceb07ed4718a2999a4d08b6 100644
--- a/src/components/player-info.js
+++ b/src/components/player-info.js
@@ -12,7 +12,7 @@ AFRAME.registerComponent("player-info", {
   pause() {
     this.el.removeEventListener("model-loaded", this.applyProperties);
   },
-  update(oldProps) {
+  update() {
     this.applyProperties();
   },
   applyProperties() {
@@ -25,7 +25,7 @@ AFRAME.registerComponent("player-info", {
 
     const modelEl = this.el.querySelector(".model");
     if (this.data.avatarSrc && modelEl) {
-      modelEl.setAttribute("src", this.data.avatarSrc);
+      modelEl.setAttribute("gltf-model-plus", "src", this.data.avatarSrc);
     }
   }
 });
diff --git a/src/components/super-cursor.js b/src/components/super-cursor.js
index e498a1acc2ad9b99c9131134bd17560bee1af531..28589171658f9d06ed57b4e604d91a73032ea583 100644
--- a/src/components/super-cursor.js
+++ b/src/components/super-cursor.js
@@ -103,7 +103,7 @@ AFRAME.registerComponent("super-cursor", {
     }
   },
 
-  _handleMouseDown: function(e) {
+  _handleMouseDown: function() {
     if (this.isInteractable) {
       const lookControls = this.data.camera.components["look-controls"];
       lookControls.pause();
@@ -115,7 +115,7 @@ AFRAME.registerComponent("super-cursor", {
     this.mousePos.set(e.clientX / window.innerWidth * 2 - 1, -(e.clientY / window.innerHeight) * 2 + 1);
   },
 
-  _handleMouseUp: function(e) {
+  _handleMouseUp: function() {
     const lookControls = this.data.camera.components["look-controls"];
     lookControls.play();
     this.data.cursor.emit("action_release", {});
@@ -125,13 +125,13 @@ AFRAME.registerComponent("super-cursor", {
     if (this.isGrabbing) this.currentDistanceMod += e.deltaY / 10;
   },
 
-  _handleEnterVR: function(e) {
+  _handleEnterVR: function() {
     if (AFRAME.utils.device.checkHeadsetConnected() || AFRAME.utils.device.isMobile()) {
       this._disable();
     }
   },
 
-  _handleExitVR: function(e) {
+  _handleExitVR: function() {
     this._enable();
   },
 
diff --git a/src/components/super-networked-interactable.js b/src/components/super-networked-interactable.js
index 4aeafecaa460379852a0ff4e9eb0f0f0d25bf9f3..a5baed53dd2f3f5404a81a26150904353434e9e9 100644
--- a/src/components/super-networked-interactable.js
+++ b/src/components/super-networked-interactable.js
@@ -11,7 +11,7 @@ AFRAME.registerComponent("super-networked-interactable", {
     NAF.utils.getNetworkedEntity(this.el).then(networkedEl => {
       this.networkedEl = networkedEl;
       if (!NAF.utils.isMine(networkedEl)) {
-        this.el.setAttribute("body", {type: "dynamic", mass: 0});
+        this.el.setAttribute("body", { type: "dynamic", mass: 0 });
       } else {
         this.counter.register(networkedEl);
       }
@@ -33,7 +33,7 @@ AFRAME.registerComponent("super-networked-interactable", {
     this.hand = e.detail.hand;
     if (this.networkedEl && !NAF.utils.isMine(this.networkedEl)) {
       if (NAF.utils.takeOwnership(this.networkedEl)) {
-        this.el.setAttribute("body", {mass: this.data.mass});
+        this.el.setAttribute("body", { mass: this.data.mass });
         this.counter.register(this.networkedEl);
       } else {
         this.el.emit("grab-end", { hand: this.hand });
@@ -42,8 +42,8 @@ AFRAME.registerComponent("super-networked-interactable", {
     }
   },
 
-  _onOwnershipLost: function(e) {
-    this.el.setAttribute("body", {mass: 0});
+  _onOwnershipLost: function() {
+    this.el.setAttribute("body", { mass: 0 });
     this.el.emit("grab-end", { hand: this.hand });
     this.hand = null;
     this.counter.deregister(this.el);
diff --git a/src/components/super-spawner.js b/src/components/super-spawner.js
index 72d24eff785b9eebed42c60e2e4feb21e659468a..bb6762a200eeacf53c29179f95554c7dc8fdab57 100644
--- a/src/components/super-spawner.js
+++ b/src/components/super-spawner.js
@@ -19,7 +19,7 @@ AFRAME.registerComponent("super-spawner", {
   },
 
   remove: function() {
-    for (let entity of this.entities.keys()) {
+    for (const entity of this.entities.keys()) {
       const data = this.entities.get(entity);
       entity.removeEventListener("componentinitialized", data.componentinInitializedListener);
       entity.removeEventListener("bodyloaded", data.bodyLoadedListener);
@@ -39,12 +39,12 @@ AFRAME.registerComponent("super-spawner", {
 
     this.entities.set(entity, {
       hand: hand,
-      componentInitialized: false, 
-      bodyLoaded: false, 
-      componentinInitializedListener: componentinInitializedListener, 
+      componentInitialized: false,
+      bodyLoaded: false,
+      componentinInitializedListener: componentinInitializedListener,
       bodyLoadedListener: bodyLoadedListener
     });
-    
+
     entity.addEventListener("componentinitialized", componentinInitializedListener);
     entity.addEventListener("body-loaded", bodyLoadedListener);
 
@@ -60,7 +60,7 @@ AFRAME.registerComponent("super-spawner", {
     }
   },
 
-  _handleBodyLoaded: function(entity, e) {
+  _handleBodyLoaded: function(entity) {
     this.entities.get(entity).bodyLoaded = true;
     this._emitEvents.call(this, entity);
   },
diff --git a/src/components/virtual-gamepad-controls.js b/src/components/virtual-gamepad-controls.js
index d399e38b29765280a677bb23289cdc3e4c2aff6b..d70219bf1e6374fefc6d6daaeb9e8e10bfc27fb8 100644
--- a/src/components/virtual-gamepad-controls.js
+++ b/src/components/virtual-gamepad-controls.js
@@ -1,10 +1,6 @@
 import nipplejs from "nipplejs";
 import styles from "./virtual-gamepad-controls.css";
 
-const THREE = AFRAME.THREE;
-const DEGREES = Math.PI / 180;
-const HALF_PI = Math.PI / 2;
-
 AFRAME.registerComponent("virtual-gamepad-controls", {
   schema: {},
 
diff --git a/src/components/wasd-to-analog2d.js b/src/components/wasd-to-analog2d.js
index a424cb893ab10c29575667f7c68539dd65e90689..35b68026bd121a35bb7f7c139d15423262475b86 100644
--- a/src/components/wasd-to-analog2d.js
+++ b/src/components/wasd-to-analog2d.js
@@ -44,7 +44,7 @@ AFRAME.registerComponent("wasd-to-analog2d", {
     this.keys[key] = down;
   },
 
-  tick: function(t, dt) {
+  tick: function() {
     this.target = [0, 0];
 
     for (const key in this.keys) {
diff --git a/src/components/water.js b/src/components/water.js
index 9152e548435ea8b184e9d1eb0f8cd4c2371217cc..b7f176131ea97f6f47fbbb31b27c59e80350b78b 100644
--- a/src/components/water.js
+++ b/src/components/water.js
@@ -17,24 +17,14 @@ function MobileWater(geometry, options) {
 
   options = options || {};
 
-  const clipBias = options.clipBias !== undefined ? options.clipBias : 0.0;
   const time = options.time !== undefined ? options.time : 0.0;
-  const normalSampler =
-    options.waterNormals !== undefined ? options.waterNormals : null;
+  const normalSampler = options.waterNormals !== undefined ? options.waterNormals : null;
   const sunDirection =
-    options.sunDirection !== undefined
-      ? options.sunDirection
-      : new THREE.Vector3(0.70707, 0.70707, 0.0);
-  const sunColor = new THREE.Color(
-    options.sunColor !== undefined ? options.sunColor : 0xffffff
-  );
-  const waterColor = new THREE.Color(
-    options.waterColor !== undefined ? options.waterColor : 0x7f7f7f
-  );
-  const eye =
-    options.eye !== undefined ? options.eye : new THREE.Vector3(0, 0, 0);
-  const distortionScale =
-    options.distortionScale !== undefined ? options.distortionScale : 20.0;
+    options.sunDirection !== undefined ? options.sunDirection : new THREE.Vector3(0.70707, 0.70707, 0.0);
+  const sunColor = new THREE.Color(options.sunColor !== undefined ? options.sunColor : 0xffffff);
+  const waterColor = new THREE.Color(options.waterColor !== undefined ? options.waterColor : 0x7f7f7f);
+  const eye = options.eye !== undefined ? options.eye : new THREE.Vector3(0, 0, 0);
+  const distortionScale = options.distortionScale !== undefined ? options.distortionScale : 20.0;
   const side = options.side !== undefined ? options.side : THREE.FrontSide;
   const fog = options.fog !== undefined ? options.fog : false;
 
diff --git a/src/elements/a-progressive-asset.js b/src/elements/a-progressive-asset.js
index 8bf922aa17b10ce6eec09baf413e93dcf6d871f1..db51b9b74484043a1f83847cc64d4ccc4bc6554c 100644
--- a/src/elements/a-progressive-asset.js
+++ b/src/elements/a-progressive-asset.js
@@ -33,7 +33,7 @@ AFRAME.registerElement("a-progressive-asset", {
           src = lowSrc;
         }
 
-        this.fileLoader.setResponseType(this.getAttribute("response-type") || inferResponseType(src));
+        this.fileLoader.setResponseType(this.getAttribute("response-type"));
         this.fileLoader.load(
           src,
           function handleOnLoad(response) {
diff --git a/src/gltf-component-mappings.js b/src/gltf-component-mappings.js
index e4cf086733ad1e0dc547abafe3fb37ed025a5608..7c6b6a8d8774eeeda0de6f0fb074848a5e78be51 100644
--- a/src/gltf-component-mappings.js
+++ b/src/gltf-component-mappings.js
@@ -1,4 +1,4 @@
-import "./elements/a-gltf-entity";
+import "./components/gltf-model-plus";
 
-AFRAME.AGLTFEntity.registerComponent("scale-audio-feedback", "scale-audio-feedback");
-AFRAME.AGLTFEntity.registerComponent("loop-animation", "loop-animation");
+AFRAME.GLTFModelPlus.registerComponent("scale-audio-feedback", "scale-audio-feedback");
+AFRAME.GLTFModelPlus.registerComponent("loop-animation", "loop-animation");
diff --git a/src/hub.html b/src/hub.html
index 1105c66efc22c08d0e791ef92ee75d4953fd0392..987d889be0bfb0224737569c3b29e31880c1a340 100644
--- a/src/hub.html
+++ b/src/hub.html
@@ -112,7 +112,7 @@
 
                     <a-entity class="right-controller"></a-entity>
 
-                    <a-gltf-entity class="model" inflate="true">
+                    <a-entity class="model" gltf-model-plus="inflate: true">
                         <template data-selector=".RootScene">
                             <a-entity ik-controller animation-mixer></a-entity>
                         </template>
@@ -145,13 +145,13 @@
                         <template data-selector=".RightHand">
                             <a-entity personal-space-invader ></a-entity>
                         </template>
-                    </a-gltf-entity>
+                    </a-entity>
                 </a-entity>
             </template>
 
             <template id="interactable-template">
                 <a-entity
-                    gltf-model="#interactable-duck"
+                    gltf-model-plus="src: #interactable-duck"
                     scale="2 2 2"
                     class="interactable" 
                     super-networked-interactable="counter: #counter; mass: 5;"
@@ -176,8 +176,8 @@
         <!-- Interactables -->
         <a-entity id="counter" networked-counter="max: 3; ttl: 120"></a-entity>
 
-        <a-entity
-            gltf-model="#interactable-duck"
+        <a-entity 
+            gltf-model-plus="src: #interactable-duck"
             scale="2 2 2"
             class="interactable" 
             super-spawner="template: #interactable-template;" 
@@ -264,7 +264,7 @@
                 app-mode-toggle-attribute__line="mode: hud; property: visible;"
             ></a-entity>
 
-            <a-gltf-entity class="model" inflate="true">
+            <a-entity gltf-model-plus="inflate: true;" class="model">
                 <template data-selector=".RootScene">
                     <a-entity
                         ik-controller
@@ -285,14 +285,14 @@
 
                 <template data-selector=".LeftHand">
                     <a-entity>
-                        <a-gltf-entity
+                        <a-entity
                             id="watch"
-                            src="#watch-model"
+                            gltf-model-plus="src: #watch-model"
                             bone-mute-state-indicator
                             scale="1.5 1.5 1.5"
                             rotation="0 -90 90"
                             position="0 -0.04 0"
-                        ></a-gltf-entity>
+                        ></a--entity>
                         <a-entity
                             event-repeater="events: action_grab, action_release; eventSource: #player-left-controller"
                             static-body="shape: sphere; sphereRadius: 0.02"
@@ -313,7 +313,7 @@
                     </a-entity>
                 </template>
 
-            </a-gltf-entity>
+            </a-entity>
         </a-entity>
 
         <!-- Lights -->
diff --git a/src/hub.js b/src/hub.js
index e29dc6cdfbe801a5938407d2ebe66d5df8fd6b2b..b6cf032d1677bbafecd557cfb5fa530ff5b63451 100644
--- a/src/hub.js
+++ b/src/hub.js
@@ -41,6 +41,7 @@ import "./components/player-info";
 import "./components/debug";
 import "./components/animation-mixer";
 import "./components/loop-animation";
+import "./components/gltf-model-plus";
 import "./components/gltf-bundle";
 
 import ReactDOM from "react-dom";
@@ -50,8 +51,6 @@ import UIRoot from "./react-components/ui-root";
 import "./systems/personal-space-bubble";
 import "./systems/app-mode";
 
-import "./elements/a-gltf-entity";
-
 import "./gltf-component-mappings";
 
 import { App } from "./App";
@@ -222,6 +221,8 @@ function mountUI(scene) {
   const enableScreenSharing = qsTruthy("enable_screen_sharing");
   const htmlPrefix = document.body.dataset.htmlPrefix || "";
 
+  // TODO: Refactor to avoid using return value
+  /* eslint-disable react/no-render-return-value */
   const uiRoot = ReactDOM.render(
     <UIRoot
       {...{
@@ -238,6 +239,7 @@ function mountUI(scene) {
     />,
     document.getElementById("ui-root")
   );
+  /* eslint-enable react/no-render-return-value */
 
   return uiRoot;
 }
diff --git a/src/index.js b/src/index.js
index 2518b42dd56390078e56b04220ed780ab6abe707..198ee8d47378bd740fc9a2891cc6945447442b2a 100644
--- a/src/index.js
+++ b/src/index.js
@@ -4,4 +4,5 @@ import ReactDOM from "react-dom";
 import HomeRoot from "./react-components/home-root";
 import registerTelemetry from "./telemetry";
 
+registerTelemetry();
 ReactDOM.render(<HomeRoot />, document.getElementById("home-root"));
diff --git a/src/react-components/2d-hud.js b/src/react-components/2d-hud.js
index fda3ae06ad0ce63b7b773d5a69f4141b52389c9f..8d9223a072c97c670210e67e48434cfc58543d49 100644
--- a/src/react-components/2d-hud.js
+++ b/src/react-components/2d-hud.js
@@ -1,8 +1,8 @@
-import React, { Component } from "react";
+import React from "react";
 import PropTypes from "prop-types";
 import cx from "classnames";
 
-import styles from "./2d-hud.css";
+import styles from "../assets/stylesheets/2d-hud.css";
 
 const TwoDHUD = ({ name, muted, onToggleMute }) => (
   <div className={styles.container}>
diff --git a/src/react-components/auto-exit-warning.js b/src/react-components/auto-exit-warning.js
index 8663dcca2277d42456511adb212238d3bcdb936f..d8691a85182c7f8bbd3ee9a7472e428df9aa2d3b 100644
--- a/src/react-components/auto-exit-warning.js
+++ b/src/react-components/auto-exit-warning.js
@@ -1,4 +1,4 @@
-import React, { Component } from "react";
+import React from "react";
 import { FormattedMessage } from "react-intl";
 import PropTypes from "prop-types";
 
diff --git a/src/react-components/avatar-selector.js b/src/react-components/avatar-selector.js
index 6ce9a8a172726bb1b6d6194e13d5f88aa29185b5..5cb456ee578bd0c966c02653296457d425c31326 100644
--- a/src/react-components/avatar-selector.js
+++ b/src/react-components/avatar-selector.js
@@ -56,7 +56,7 @@ class AvatarSelector extends Component {
 
     const avatarEntities = this.props.avatars.map((avatar, i) => (
       <a-entity key={avatar.id} position="0 0 0" rotation={`0 ${360 * -i / this.props.avatars.length} 0`}>
-        <a-gltf-entity position="0 0 5" rotation="0 0 0" src={"#" + avatar.id} inflate="true">
+        <a-entity position="0 0 5" rotation="0 0 0" gltf-model-plus={`src: #${avatar.id}`} inflate="true">
           <template data-selector=".RootScene">
             <a-entity animation-mixer />
           </template>
@@ -66,7 +66,7 @@ class AvatarSelector extends Component {
             to={`0 ${this.getAvatarIndex() === i ? 360 : 0} 0`}
             repeat="indefinite"
           />
-        </a-gltf-entity>
+        </a-entity>
       </a-entity>
     ));
 
@@ -100,7 +100,7 @@ class AvatarSelector extends Component {
             position="0 5 -15"
           />
           <a-entity hide-when-quality="low" light="type: ambient; color: #FFF" />
-          <a-gltf-entity id="meeting-space" src="#meeting-space1-mesh" position="0 0 0" />
+          <a-entity id="meeting-space" gltf-model-plus="src: #meeting-space1-mesh" position="0 0 0" />
         </a-scene>
         <button className="avatar-selector__previous-button" onClick={this.emitChangeToPrevious}>
           <FontAwesomeIcon icon={faAngleLeft} />
diff --git a/src/react-components/entry-buttons.js b/src/react-components/entry-buttons.js
index e567c6e1111f362ab4175087794af1f38a6a1c24..8ed8b171d6e7ec08d82d59af2c5314d9353a7910 100644
--- a/src/react-components/entry-buttons.js
+++ b/src/react-components/entry-buttons.js
@@ -1,28 +1,28 @@
-import React, { Component } from "react";
+import React from "react";
 import { FormattedMessage } from "react-intl";
 import PropTypes from "prop-types";
-import MobileDetect from 'mobile-detect';
+import MobileDetect from "mobile-detect";
 
-import MobileScreenEntryImg from '../assets/images/mobile_screen_entry.svg';
-import DesktopScreenEntryImg from '../assets/images/desktop_screen_entry.svg';
-import GenericVREntryImg from '../assets/images/generic_vr_entry.svg';
-import GearVREntryImg from '../assets/images/gearvr_entry.svg';
-import DaydreamEntyImg from '../assets/images/daydream_entry.svg';
+import MobileScreenEntryImg from "../assets/images/mobile_screen_entry.svg";
+import DesktopScreenEntryImg from "../assets/images/desktop_screen_entry.svg";
+import GenericVREntryImg from "../assets/images/generic_vr_entry.svg";
+import GearVREntryImg from "../assets/images/gearvr_entry.svg";
+import DaydreamEntyImg from "../assets/images/daydream_entry.svg";
 
 const mobiledetect = new MobileDetect(navigator.userAgent);
 
-const EntryButton = (props) => (
-  <div className="entry-button" onClick={ props.onClick }>
-    <img src={props.iconSrc} className="entry-button__icon"/>
+const EntryButton = props => (
+  <div className="entry-button" onClick={props.onClick}>
+    <img src={props.iconSrc} className="entry-button__icon" />
     <div className="entry-button__label">
       <div className="entry-button__label__contents">
         <span>
-          <FormattedMessage id={ props.prefixMessageId }/>
+          <FormattedMessage id={props.prefixMessageId} />
         </span>
         <span className="entry-button--bolded">
-          <FormattedMessage id={ props.mediumMessageId }/>
+          <FormattedMessage id={props.mediumMessageId} />
         </span>
-        { props.subtitle && (<div className="entry-button__subtitle">{props.subtitle}</div>) }
+        {props.subtitle && <div className="entry-button__subtitle">{props.subtitle}</div>}
       </div>
     </div>
   </div>
@@ -34,20 +34,20 @@ EntryButton.propTypes = {
   prefixMessageId: PropTypes.string,
   mediumMessageId: PropTypes.string,
   subtitle: PropTypes.string
-}
+};
 
-export const TwoDEntryButton = (props) => {
+export const TwoDEntryButton = props => {
   const entryButtonProps = {
     ...props,
     iconSrc: mobiledetect.mobile() ? MobileScreenEntryImg : DesktopScreenEntryImg,
     prefixMessageId: "entry.screen-prefix",
-    mediumMessageId: mobiledetect.mobile() ? "entry.mobile-screen" : "entry.desktop-screen" 
+    mediumMessageId: mobiledetect.mobile() ? "entry.mobile-screen" : "entry.desktop-screen"
   };
 
-  return (<EntryButton  {...entryButtonProps}/>);
-}
+  return <EntryButton {...entryButtonProps} />;
+};
 
-export const GenericEntryButton = (props) => {
+export const GenericEntryButton = props => {
   const entryButtonProps = {
     ...props,
     iconSrc: GenericVREntryImg,
@@ -55,10 +55,10 @@ export const GenericEntryButton = (props) => {
     mediumMessageId: "entry.generic-medium"
   };
 
-  return (<EntryButton {...entryButtonProps}/>);
+  return <EntryButton {...entryButtonProps} />;
 };
 
-export const GearVREntryButton = (props) => {
+export const GearVREntryButton = props => {
   const entryButtonProps = {
     ...props,
     iconSrc: GearVREntryImg,
@@ -66,10 +66,10 @@ export const GearVREntryButton = (props) => {
     mediumMessageId: "entry.gearvr-medium"
   };
 
-  return (<EntryButton  {...entryButtonProps}/>);
+  return <EntryButton {...entryButtonProps} />;
 };
 
-export const DaydreamEntryButton = (props) => {
+export const DaydreamEntryButton = props => {
   const entryButtonProps = {
     ...props,
     iconSrc: DaydreamEntyImg,
@@ -77,6 +77,5 @@ export const DaydreamEntryButton = (props) => {
     mediumMessageId: "entry.daydream-medium"
   };
 
-  return (<EntryButton  {...entryButtonProps}/>);
+  return <EntryButton {...entryButtonProps} />;
 };
-
diff --git a/src/react-components/home-root.js b/src/react-components/home-root.js
index 332904b9db9fd29ecb8b57f6e929cf07127d78f3..984a3d44c2fbb29973c3f1ed9dee4395d015bfa2 100644
--- a/src/react-components/home-root.js
+++ b/src/react-components/home-root.js
@@ -1,8 +1,6 @@
 import React, { Component } from "react";
 import PropTypes from "prop-types";
-import classNames from "classnames";
-import queryString from "query-string";
-import { IntlProvider, injectIntl, FormattedMessage, addLocaleData } from "react-intl";
+import { IntlProvider, FormattedMessage, addLocaleData } from "react-intl";
 import en from "react-intl/locale-data/en";
 import homeVideo from "../assets/video/home.webm";
 
diff --git a/src/react-components/profile-entry-panel.js b/src/react-components/profile-entry-panel.js
index 7dd04d84ecbaab4ae3248fbab400579d3d621135..f5225de8d982b1c7249a0b3a16c541300bcc784c 100644
--- a/src/react-components/profile-entry-panel.js
+++ b/src/react-components/profile-entry-panel.js
@@ -1,6 +1,6 @@
-import React, { Component } from 'react';
-import PropTypes from 'prop-types';
-import { injectIntl, FormattedMessage } from 'react-intl';
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { injectIntl, FormattedMessage } from "react-intl";
 import { SCHEMA } from "../storage/store";
 
 class ProfileEntryPanel extends Component {
@@ -8,14 +8,15 @@ class ProfileEntryPanel extends Component {
     store: PropTypes.object,
     messages: PropTypes.object,
     finished: PropTypes.func,
-    htmlPrefix: PropTypes.string
-  }
+    htmlPrefix: PropTypes.string,
+    intl: PropTypes.object
+  };
 
   constructor(props) {
     super(props);
     this.state = {
       display_name: this.props.store.state.profile.display_name,
-      avatar_id: this.props.store.state.profile.avatar_id,
+      avatar_id: this.props.store.state.profile.avatar_id
     };
     this.props.store.addEventListener("statechanged", this.storeUpdated);
   }
@@ -23,64 +24,72 @@ class ProfileEntryPanel extends Component {
   storeUpdated = () => {
     const { avatar_id, display_name } = this.props.store.state.profile;
     this.setState({ avatar_id, display_name });
-  }
+  };
 
-  saveStateAndFinish = (e) => {
+  saveStateAndFinish = e => {
     e.preventDefault();
-    this.props.store.update({profile: {
-      display_name: this.state.display_name,
-      avatar_id: this.state.avatar_id
-    }});
+    this.props.store.update({
+      profile: {
+        display_name: this.state.display_name,
+        avatar_id: this.state.avatar_id
+      }
+    });
     this.props.finished();
-  }
+  };
 
-  stopPropagation = (e) => {
+  stopPropagation = e => {
     e.stopPropagation();
-  }
+  };
 
-  setAvatarStateFromIframeMessage = (e) => {
-    if (e.source !== this.avatarSelector.contentWindow) { return; }
-    this.setState({avatar_id: e.data.avatarId});
-  }
+  setAvatarStateFromIframeMessage = e => {
+    if (e.source !== this.avatarSelector.contentWindow) {
+      return;
+    }
+    this.setState({ avatar_id: e.data.avatarId });
+  };
 
   componentDidMount() {
     // stop propagation so that avatar doesn't move when wasd'ing during text input.
-    this.nameInput.addEventListener('keydown', this.stopPropagation);
-    this.nameInput.addEventListener('keypress', this.stopPropagation);
-    this.nameInput.addEventListener('keyup', this.stopPropagation);
-    window.addEventListener('message', this.setAvatarStateFromIframeMessage);
+    this.nameInput.addEventListener("keydown", this.stopPropagation);
+    this.nameInput.addEventListener("keypress", this.stopPropagation);
+    this.nameInput.addEventListener("keyup", this.stopPropagation);
+    window.addEventListener("message", this.setAvatarStateFromIframeMessage);
   }
 
   componentWillUnmount() {
-    this.props.store.removeEventListener('statechanged', this.storeUpdated);
-    this.nameInput.removeEventListener('keydown', this.stopPropagation);
-    this.nameInput.removeEventListener('keypress', this.stopPropagation);
-    this.nameInput.removeEventListener('keyup', this.stopPropagation);
-    window.removeEventListener('message', this.setAvatarStateFromIframeMessage);
+    this.props.store.removeEventListener("statechanged", this.storeUpdated);
+    this.nameInput.removeEventListener("keydown", this.stopPropagation);
+    this.nameInput.removeEventListener("keypress", this.stopPropagation);
+    this.nameInput.removeEventListener("keyup", this.stopPropagation);
+    window.removeEventListener("message", this.setAvatarStateFromIframeMessage);
   }
 
-  render () {
+  render() {
     const { formatMessage } = this.props.intl;
 
     return (
       <div className="profile-entry">
         <form onSubmit={this.saveStateAndFinish}>
-        <div className="profile-entry__box profile-entry__box--darkened">
-          <div className="profile-entry__subtitle">
-            <FormattedMessage id="profile.header"/>
+          <div className="profile-entry__box profile-entry__box--darkened">
+            <div className="profile-entry__subtitle">
+              <FormattedMessage id="profile.header" />
+            </div>
+            <input
+              className="profile-entry__form-field-text"
+              value={this.state.display_name}
+              onChange={e => this.setState({ display_name: e.target.value })}
+              required
+              pattern={SCHEMA.definitions.profile.properties.display_name.pattern}
+              title={formatMessage({ id: "profile.display_name.validation_warning" })}
+              ref={inp => (this.nameInput = inp)}
+            />
+            <iframe
+              className="profile-entry__avatar-selector"
+              src={`${this.props.htmlPrefix}avatar-selector.html#avatar_id=${this.state.avatar_id}`}
+              ref={ifr => (this.avatarSelector = ifr)}
+            />
+            <input className="profile-entry__form-submit" type="submit" value={formatMessage({ id: "profile.save" })} />
           </div>
-          <input
-            className="profile-entry__form-field-text"
-            value={this.state.display_name} onChange={(e) => this.setState({display_name: e.target.value})}
-            required pattern={SCHEMA.definitions.profile.properties.display_name.pattern}
-            title={formatMessage({ id: "profile.display_name.validation_warning" })}
-            ref={inp => this.nameInput = inp}/>
-          <iframe
-            className="profile-entry__avatar-selector"
-            src={`${this.props.htmlPrefix}avatar-selector.html#avatar_id=${this.state.avatar_id}`}
-            ref={ifr => this.avatarSelector = ifr}></iframe>
-          <input className="profile-entry__form-submit" type="submit" value={formatMessage({ id: "profile.save" }) }/>
-        </div>
         </form>
       </div>
     );
diff --git a/src/react-components/profile-info-header.js b/src/react-components/profile-info-header.js
index 6b9e82bb124e931dbf59b0867eaa3e36aae431f2..43ec49291007089d54e1d1a52c7a586e766e5fb4 100644
--- a/src/react-components/profile-info-header.js
+++ b/src/react-components/profile-info-header.js
@@ -1,4 +1,4 @@
-import React, { Component } from "react";
+import React from "react";
 import PropTypes from "prop-types";
 
 export const ProfileInfoHeader = props => (
diff --git a/src/react-components/ui-root.js b/src/react-components/ui-root.js
index b2b0633927428f93787298216536a247fed4d815..397621adc442b4ef7089588ff1eb3484c30891a9 100644
--- a/src/react-components/ui-root.js
+++ b/src/react-components/ui-root.js
@@ -3,7 +3,6 @@ import PropTypes from "prop-types";
 import classNames from "classnames";
 import { VR_DEVICE_AVAILABILITY } from "../utils/vr-caps-detect";
 import queryString from "query-string";
-import { SCHEMA } from "../storage/store";
 import MobileDetect from "mobile-detect";
 import { IntlProvider, FormattedMessage, addLocaleData } from "react-intl";
 import en from "react-intl/locale-data/en";
@@ -34,13 +33,6 @@ const ENTRY_STEPS = {
   finished: "finished"
 };
 
-const HMD_MIC_REGEXES = [/\Wvive\W/i, /\Wrift\W/i];
-
-async function grantedMicLabels() {
-  const mediaDevices = await navigator.mediaDevices.enumerateDevices();
-  return mediaDevices.filter(d => d.label && d.kind === "audioinput").map(d => d.label);
-}
-
 // This is a list of regexes that match the microphone labels of HMDs.
 //
 // If entering VR mode, and if any of these regexes match an audio device,
@@ -49,13 +41,19 @@ async function grantedMicLabels() {
 //
 // Note that this doesn't have to be exhaustive: if no devices match any regex
 // then we rely upon the user to select the proper mic.
-const VR_DEVICE_MIC_LABEL_REGEXES = [];
+const HMD_MIC_REGEXES = [/\Wvive\W/i, /\Wrift\W/i];
+
+async function grantedMicLabels() {
+  const mediaDevices = await navigator.mediaDevices.enumerateDevices();
+  return mediaDevices.filter(d => d.label && d.kind === "audioinput").map(d => d.label);
+}
 
 const AUTO_EXIT_TIMER_SECONDS = 10;
 
 class UIRoot extends Component {
   static propTypes = {
     enterScene: PropTypes.func,
+    exitScene: PropTypes.func,
     concurrentLoadDetector: PropTypes.object,
     disableAutoExitOnConcurrentLoad: PropTypes.bool,
     forcedVREntryType: PropTypes.string,
@@ -254,32 +252,32 @@ class UIRoot extends Component {
     this.exit();
 
     // Launch via Oculus Browser
-    const qs = queryString.parse(document.location.search);
+    const location = window.location;
+    const qs = queryString.parse(location.search);
     qs.vr_entry_type = "gearvr"; // Auto-choose 'gearvr' after landing in Oculus Browser
 
-    const ovrwebUrl = `ovrweb://${document.location.protocol || "http:"}//${document.location.host}${document.location
-      .pathname || ""}?${queryString.stringify(qs)}#{document.location.hash || ""}`;
+    const ovrwebUrl =
+      `ovrweb://${location.protocol || "http:"}//${location.host}` +
+      `${location.pathname || ""}?${queryString.stringify(qs)}#${location.hash || ""}`;
 
-    document.location = ovrwebUrl;
+    window.location = ovrwebUrl;
   };
 
   enterDaydream = async () => {
-    const loc = document.location;
-
     if (this.state.availableVREntryTypes.daydream == VR_DEVICE_AVAILABILITY.maybe) {
       this.exit();
 
       // We are not in mobile chrome, so launch into chrome via an Intent URL
-      const qs = queryString.parse(document.location.search);
+      const location = window.location;
+      const qs = queryString.parse(location.search);
       qs.vr_entry_type = "daydream"; // Auto-choose 'daydream' after landing in chrome
 
-      const intentUrl = `intent://${document.location.host}${document.location.pathname || ""}?${queryString.stringify(
-        qs
-      )}#Intent;scheme=${(document.location.protocol || "http:").replace(
-        ":",
-        ""
-      )};action=android.intent.action.VIEW;package=com.android.chrome;end;`;
-      document.location = intentUrl;
+      const intentUrl =
+        `intent://${location.host}${location.pathname || ""}?` +
+        `${queryString.stringify(qs)}#Intent;scheme=${(location.protocol || "http:").replace(":", "")};` +
+        `action=android.intent.action.VIEW;package=com.android.chrome;end;`;
+
+      window.location = intentUrl;
     } else {
       await this.performDirectEntryFlow(true);
     }
@@ -328,7 +326,7 @@ class UIRoot extends Component {
     this.setState({ audioTrack: mediaStream.getAudioTracks()[0] });
   };
 
-  setupNewMediaStream = async constraints => {
+  setupNewMediaStream = async () => {
     const mediaStream = new MediaStream();
 
     // we should definitely have an audioTrack at this point.
diff --git a/src/storage/store.js b/src/storage/store.js
index 037e27bf1d9603a210423baa5ee6543c2ee77fdb..a517bfc5749c53eb9a193298194f7ed7fa0ee56f 100644
--- a/src/storage/store.js
+++ b/src/storage/store.js
@@ -4,7 +4,7 @@ import { Validator } from "jsonschema";
 const LOCAL_STORE_KEY = "___mozilla_duck";
 const STORE_STATE_CACHE_KEY = Symbol();
 const validator = new Validator();
-import { EventTarget } from "event-target-shim"
+import { EventTarget } from "event-target-shim";
 
 // Durable (via local-storage) schema-enforced state that is meant to be consumed via forward data flow.
 // (Think flux but with way less incidental complexity, at least for now :))
@@ -17,7 +17,7 @@ export const SCHEMA = {
       additionalProperties: false,
       properties: {
         display_name: { type: "string", pattern: "^[A-Za-z0-9-]{3,32}$" },
-        avatar_id: { type: "string" },
+        avatar_id: { type: "string" }
       }
     }
   },
@@ -26,11 +26,11 @@ export const SCHEMA = {
 
   properties: {
     id: { type: "string", pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" },
-    profile: { "$ref": "#/definitions/profile" },
+    profile: { $ref: "#/definitions/profile" }
   },
 
   additionalProperties: false
-}
+};
 
 export default class Store extends EventTarget {
   constructor() {
@@ -51,15 +51,14 @@ export default class Store extends EventTarget {
 
   update(newState) {
     if (newState.id) {
-      throw "Store id is immutable.";
+      throw new Error("Store id is immutable.");
     }
 
     const finalState = { ...this.state, ...newState };
     const isValid = validator.validate(finalState, SCHEMA).valid;
 
     if (!isValid) {
-      throw `Write of ${JSON.stringify(finalState)} to store failed schema validation.`;
-      return;
+      throw new Error(`Write of ${JSON.stringify(finalState)} to store failed schema validation.`);
     }
 
     localStorage.setItem(LOCAL_STORE_KEY, JSON.stringify(finalState));
diff --git a/src/systems/app-mode.js b/src/systems/app-mode.js
index 421bf48abbab344b877f9805803a7dfe30472336..1ef1c2cd0d7b79c75d0738717558901370320e5b 100644
--- a/src/systems/app-mode.js
+++ b/src/systems/app-mode.js
@@ -188,7 +188,7 @@ AFRAME.registerComponent("vr-mode-toggle-visibility", {
     this.el.sceneEl.removeEventListener("exit-vr", this.updateComponentState);
   },
 
-  updateComponentState(i) {
+  updateComponentState() {
     const inVRMode = this.el.sceneEl.is("vr-mode");
     this.el.setAttribute("visible", inVRMode !== this.data.invert);
   }
@@ -218,7 +218,7 @@ AFRAME.registerComponent("vr-mode-toggle-playing", {
     this.el.sceneEl.removeEventListener("exit-vr", this.updateComponentState);
   },
 
-  updateComponentState(i) {
+  updateComponentState() {
     const componentName = this.id;
     const inVRMode = this.el.sceneEl.is("vr-mode");
     this.el.components[componentName][inVRMode !== this.data.invert ? "play" : "pause"]();
diff --git a/src/systems/personal-space-bubble.js b/src/systems/personal-space-bubble.js
index 563818408f3dc4ea594f5ba2bc2eb10749dca5d2..e696705e138159a67c79e9707fe97bada64a9a58 100644
--- a/src/systems/personal-space-bubble.js
+++ b/src/systems/personal-space-bubble.js
@@ -39,16 +39,16 @@ AFRAME.registerSystem("personal-space-bubble", {
 
   tick() {
     // Update matrix positions once for each space bubble and space invader
-    for (var i = 0; i < this.bubbles.length; i++) {
+    for (let i = 0; i < this.bubbles.length; i++) {
       this.bubbles[i].object3D.updateMatrixWorld(true);
     }
 
-    for (var i = 0; i < this.invaders.length; i++) {
+    for (let i = 0; i < this.invaders.length; i++) {
       this.invaders[i].object3D.updateMatrixWorld(true);
     }
 
     // Loop through all of the space bubbles (usually one)
-    for (var i = 0; i < this.bubbles.length; i++) {
+    for (let i = 0; i < this.bubbles.length; i++) {
       const bubble = this.bubbles[i];
 
       bubblePos.setFromMatrixPosition(bubble.object3D.matrixWorld);
diff --git a/src/utils/concurrent-load-detector.js b/src/utils/concurrent-load-detector.js
index f05f619a3f51fab56a33acaec2eeb3b75e48ccea..53fc2d1fa37c1f236966211ba2abaaf4631960de 100644
--- a/src/utils/concurrent-load-detector.js
+++ b/src/utils/concurrent-load-detector.js
@@ -3,7 +3,7 @@
 // events.
 
 const LOCAL_STORE_KEY = "___concurrent_load_detector";
-import { EventTarget } from "event-target-shim"
+import { EventTarget } from "event-target-shim";
 
 export default class ConcurrentLoadDetector extends EventTarget {
   constructor(instanceKey) {
@@ -20,17 +20,17 @@ export default class ConcurrentLoadDetector extends EventTarget {
 
     // Check for concurrent load every second
     this.interval = setInterval(this._step, 1000);
-  }
+  };
 
   stop = () => {
     if (this.interval) {
       clearInterval(this.interval);
     }
-  }
+  };
 
   localStorageKey = () => {
     return `${LOCAL_STORE_KEY}_${this.instanceKey}`;
-  }
+  };
 
   _step = () => {
     const currentState = JSON.parse(localStorage.getItem(this.localStorageKey()));
@@ -40,5 +40,5 @@ export default class ConcurrentLoadDetector extends EventTarget {
       this.dispatchEvent(new CustomEvent("concurrentload"));
       this.stop();
     }
-  }
+  };
 }
diff --git a/src/utils/dpad.js b/src/utils/dpad.js
index 6f135122e6408bfdbc0a5a87d373104cbdc2e9bd..c603e75ad35438deb4311be5bdc68b64022da776 100644
--- a/src/utils/dpad.js
+++ b/src/utils/dpad.js
@@ -13,7 +13,7 @@ export function angleTo4Direction(angle) {
 
 export function angleTo8Direction(angle) {
   angle = (angle * THREE.Math.RAD2DEG + 180 + 45) % 360;
-  var direction = "";
+  let direction = "";
   if ((angle >= 0 && angle < 120) || angle >= 330) {
     direction += "north";
   }
diff --git a/src/utils/identity.js b/src/utils/identity.js
index 118fe30812c1013cb64c47a335c24465a12f6636..def830cbc4df4d9d1a71b0c10d7b54c5a5610b4a 100644
--- a/src/utils/identity.js
+++ b/src/utils/identity.js
@@ -164,7 +164,7 @@ const names = [
 ];
 
 function selectRandom(arr) {
-   return arr[Math.floor(Math.random() * arr.length)]
+  return arr[Math.floor(Math.random() * arr.length)];
 }
 
 export const avatarIds = avatars.map(av => av.id);
@@ -172,7 +172,7 @@ export const avatarIds = avatars.map(av => av.id);
 export function generateDefaultProfile() {
   const name = selectRandom(names);
   return {
-    display_name: name.replace(/^./, name[0].toUpperCase()) ,
+    display_name: name.replace(/^./, name[0].toUpperCase()),
     avatar_id: selectRandom(avatarIds)
   };
 }
diff --git a/webpack.config.js b/webpack.config.js
index cd37e36c9064668355d7232ef37703ae6047bc0b..44b833cedd8bd6882cb629bec366d44014003de3 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -17,8 +17,6 @@ function createHTTPSConfig() {
     return false;
   }
 
-  let https;
-
   // Generate certs for the local webpack-dev-server.
   if (fs.existsSync(path.join(__dirname, "certs"))) {
     const key = fs.readFileSync(path.join(__dirname, "certs", "key.pem"));
@@ -215,7 +213,8 @@ const config = {
       inject: "head"
     }),
     // Extract required css and add a content hash.
-    new ExtractTextPlugin("assets/stylesheets/[name]-[contenthash].css", {
+    new ExtractTextPlugin({
+      filename: "assets/stylesheets/[name]-[contenthash].css",
       disable: process.env.NODE_ENV !== "production"
     }),
     // Transform the output of the html-loader using _.template
diff --git a/yarn.lock b/yarn.lock
index 18f7727e7dd7217a0e581df22c2ddfc12a8ff16a..41b0864f101f538ab31881607f082cfbc8b50cd7 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -440,16 +440,6 @@ assert@^1.1.1, assert@^1.4.0:
   dependencies:
     util "0.10.3"
 
-assets-webpack-plugin@^3.5.1:
-  version "3.5.1"
-  resolved "https://registry.yarnpkg.com/assets-webpack-plugin/-/assets-webpack-plugin-3.5.1.tgz#931ce0d66d42e88ed5e7f18d65522943c57a387d"
-  dependencies:
-    camelcase "^1.2.1"
-    escape-string-regexp "^1.0.3"
-    lodash.assign "^3.2.0"
-    lodash.merge "^3.3.2"
-    mkdirp "^0.5.1"
-
 assign-symbols@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367"
@@ -1309,7 +1299,7 @@ block-stream@*:
   dependencies:
     inherits "~2.0.0"
 
-bluebird@^3.4.7, bluebird@^3.5.1:
+bluebird@^3.5.1:
   version "3.5.1"
   resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz#d9551f9de98f1fcda1e683d17ee91a0602ee2eb9"
 
@@ -1719,10 +1709,6 @@ camelcase-keys@^2.0.0:
     camelcase "^2.0.0"
     map-obj "^1.0.0"
 
-camelcase@^1.2.1:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-1.2.1.tgz#9bb5304d2e0b56698b2c758b08a3eaa9daa58a39"
-
 camelcase@^2.0.0:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f"
@@ -2829,6 +2815,16 @@ error@^7.0.2:
     string-template "~0.2.1"
     xtend "~4.0.0"
 
+es-abstract@^1.5.1:
+  version "1.11.0"
+  resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.11.0.tgz#cce87d518f0496893b1a30cd8461835535480681"
+  dependencies:
+    es-to-primitive "^1.1.1"
+    function-bind "^1.1.1"
+    has "^1.0.1"
+    is-callable "^1.1.3"
+    is-regex "^1.0.4"
+
 es-abstract@^1.7.0:
   version "1.10.0"
   resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.10.0.tgz#1ecb36c197842a00d8ee4c2dfd8646bb97d60864"
@@ -2858,7 +2854,7 @@ escape-html@^1.0.3, escape-html@~1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
 
-escape-string-regexp@^1.0.0, escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.3, escape-string-regexp@^1.0.5:
+escape-string-regexp@^1.0.0, escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
 
@@ -3884,17 +3880,17 @@ html-minifier@^3.2.3, html-minifier@^3.5.8:
     relateurl "0.2.x"
     uglify-js "3.3.x"
 
-html-webpack-plugin@webpack-contrib/html-webpack-plugin:
-  version "2.30.1"
-  resolved "https://codeload.github.com/webpack-contrib/html-webpack-plugin/tar.gz/1dee37e2696fd3990fe9be0a2945e465c0bb9a64"
+html-webpack-plugin@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-3.1.0.tgz#6e02baaedb1e906310917f03239c793a75af2885"
   dependencies:
-    bluebird "^3.4.7"
     html-minifier "^3.2.3"
     loader-utils "^0.2.16"
     lodash "^4.17.3"
     pretty-error "^2.0.2"
     tapable "^1.0.0"
     toposort "^1.0.0"
+    util.promisify "1.0.0"
 
 htmlescape@^1.1.0:
   version "1.1.1"
@@ -4745,57 +4741,6 @@ locate-path@^2.0.0:
     p-locate "^2.0.0"
     path-exists "^3.0.0"
 
-lodash._arraycopy@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/lodash._arraycopy/-/lodash._arraycopy-3.0.0.tgz#76e7b7c1f1fb92547374878a562ed06a3e50f6e1"
-
-lodash._arrayeach@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/lodash._arrayeach/-/lodash._arrayeach-3.0.0.tgz#bab156b2a90d3f1bbd5c653403349e5e5933ef9e"
-
-lodash._baseassign@^3.0.0:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/lodash._baseassign/-/lodash._baseassign-3.2.0.tgz#8c38a099500f215ad09e59f1722fd0c52bfe0a4e"
-  dependencies:
-    lodash._basecopy "^3.0.0"
-    lodash.keys "^3.0.0"
-
-lodash._basecopy@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz#8da0e6a876cf344c0ad8a54882111dd3c5c7ca36"
-
-lodash._basefor@^3.0.0:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/lodash._basefor/-/lodash._basefor-3.0.3.tgz#7550b4e9218ef09fad24343b612021c79b4c20c2"
-
-lodash._bindcallback@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/lodash._bindcallback/-/lodash._bindcallback-3.0.1.tgz#e531c27644cf8b57a99e17ed95b35c748789392e"
-
-lodash._createassigner@^3.0.0:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/lodash._createassigner/-/lodash._createassigner-3.1.1.tgz#838a5bae2fdaca63ac22dee8e19fa4e6d6970b11"
-  dependencies:
-    lodash._bindcallback "^3.0.0"
-    lodash._isiterateecall "^3.0.0"
-    lodash.restparam "^3.0.0"
-
-lodash._getnative@^3.0.0:
-  version "3.9.1"
-  resolved "https://registry.yarnpkg.com/lodash._getnative/-/lodash._getnative-3.9.1.tgz#570bc7dede46d61cdcde687d65d3eecbaa3aaff5"
-
-lodash._isiterateecall@^3.0.0:
-  version "3.0.9"
-  resolved "https://registry.yarnpkg.com/lodash._isiterateecall/-/lodash._isiterateecall-3.0.9.tgz#5203ad7ba425fae842460e696db9cf3e6aac057c"
-
-lodash.assign@^3.2.0:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-3.2.0.tgz#3ce9f0234b4b2223e296b8fa0ac1fee8ebca64fa"
-  dependencies:
-    lodash._baseassign "^3.0.0"
-    lodash._createassigner "^3.0.0"
-    lodash.keys "^3.0.0"
-
 lodash.assign@^4.2.0:
   version "4.2.0"
   resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-4.2.0.tgz#0d99f3ccd7a6d261d19bdaeb9245005d285808e7"
@@ -4812,49 +4757,14 @@ lodash.endswith@^4.2.1:
   version "4.2.1"
   resolved "https://registry.yarnpkg.com/lodash.endswith/-/lodash.endswith-4.2.1.tgz#fed59ac1738ed3e236edd7064ec456448b37bc09"
 
-lodash.isarguments@^3.0.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a"
-
-lodash.isarray@^3.0.0:
-  version "3.0.4"
-  resolved "https://registry.yarnpkg.com/lodash.isarray/-/lodash.isarray-3.0.4.tgz#79e4eb88c36a8122af86f844aa9bcd851b5fbb55"
-
 lodash.isfunction@^3.0.8:
   version "3.0.9"
   resolved "https://registry.yarnpkg.com/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz#06de25df4db327ac931981d1bdb067e5af68d051"
 
-lodash.isplainobject@^3.0.0:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-3.2.0.tgz#9a8238ae16b200432960cd7346512d0123fbf4c5"
-  dependencies:
-    lodash._basefor "^3.0.0"
-    lodash.isarguments "^3.0.0"
-    lodash.keysin "^3.0.0"
-
 lodash.isstring@^4.0.1:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451"
 
-lodash.istypedarray@^3.0.0:
-  version "3.0.6"
-  resolved "https://registry.yarnpkg.com/lodash.istypedarray/-/lodash.istypedarray-3.0.6.tgz#c9a477498607501d8e8494d283b87c39281cef62"
-
-lodash.keys@^3.0.0:
-  version "3.1.2"
-  resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-3.1.2.tgz#4dbc0472b156be50a0b286855d1bd0b0c656098a"
-  dependencies:
-    lodash._getnative "^3.0.0"
-    lodash.isarguments "^3.0.0"
-    lodash.isarray "^3.0.0"
-
-lodash.keysin@^3.0.0:
-  version "3.0.8"
-  resolved "https://registry.yarnpkg.com/lodash.keysin/-/lodash.keysin-3.0.8.tgz#22c4493ebbedb1427962a54b445b2c8a767fb47f"
-  dependencies:
-    lodash.isarguments "^3.0.0"
-    lodash.isarray "^3.0.0"
-
 lodash.memoize@^4.1.2:
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
@@ -4863,30 +4773,10 @@ lodash.memoize@~3.0.3:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-3.0.4.tgz#2dcbd2c287cbc0a55cc42328bd0c736150d53e3f"
 
-lodash.merge@^3.3.2:
-  version "3.3.2"
-  resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-3.3.2.tgz#0d90d93ed637b1878437bb3e21601260d7afe994"
-  dependencies:
-    lodash._arraycopy "^3.0.0"
-    lodash._arrayeach "^3.0.0"
-    lodash._createassigner "^3.0.0"
-    lodash._getnative "^3.0.0"
-    lodash.isarguments "^3.0.0"
-    lodash.isarray "^3.0.0"
-    lodash.isplainobject "^3.0.0"
-    lodash.istypedarray "^3.0.0"
-    lodash.keys "^3.0.0"
-    lodash.keysin "^3.0.0"
-    lodash.toplainobject "^3.0.0"
-
 lodash.mergewith@^4.6.0:
   version "4.6.1"
   resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.1.tgz#639057e726c3afbdb3e7d42741caa8d6e4335927"
 
-lodash.restparam@^3.0.0:
-  version "3.6.1"
-  resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805"
-
 lodash.startswith@^4.2.1:
   version "4.2.1"
   resolved "https://registry.yarnpkg.com/lodash.startswith/-/lodash.startswith-4.2.1.tgz#c598c4adce188a27e53145731cdc6c0e7177600c"
@@ -4895,13 +4785,6 @@ lodash.tail@^4.1.1:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/lodash.tail/-/lodash.tail-4.1.1.tgz#d2333a36d9e7717c8ad2f7cacafec7c32b444664"
 
-lodash.toplainobject@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/lodash.toplainobject/-/lodash.toplainobject-3.0.0.tgz#28790ad942d293d78aa663a07ecf7f52ca04198d"
-  dependencies:
-    lodash._basecopy "^3.0.0"
-    lodash.keysin "^3.0.0"
-
 lodash.uniq@^4.5.0:
   version "4.5.0"
   resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
@@ -4995,10 +4878,6 @@ map-visit@^1.0.0:
   dependencies:
     object-visit "^1.0.0"
 
-material-design-lite@^1.3.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/material-design-lite/-/material-design-lite-1.3.0.tgz#d004ce3fee99a1eeb74a78b8a325134a5f1171d3"
-
 math-expression-evaluator@^1.2.14:
   version "1.2.17"
   resolved "https://registry.yarnpkg.com/math-expression-evaluator/-/math-expression-evaluator-1.2.17.tgz#de819fdbcd84dccd8fae59c6aeb79615b9d266ac"
@@ -5603,6 +5482,13 @@ object-visit@^1.0.0:
   dependencies:
     isobject "^3.0.0"
 
+object.getownpropertydescriptors@^2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.0.3.tgz#8758c846f5b407adab0f236e0986f14b051caa16"
+  dependencies:
+    define-properties "^1.1.2"
+    es-abstract "^1.5.1"
+
 object.omit@^2.0.0:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/object.omit/-/object.omit-2.0.1.tgz#1a9c744829f39dbb858c76ca3579ae2a54ebd1fa"
@@ -8073,6 +7959,13 @@ util-deprecate@~1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
 
+util.promisify@1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/util.promisify/-/util.promisify-1.0.0.tgz#440f7165a459c9a16dc145eb8e72f35687097030"
+  dependencies:
+    define-properties "^1.1.2"
+    object.getownpropertydescriptors "^2.0.3"
+
 util@0.10.3, util@^0.10.3, util@~0.10.1:
   version "0.10.3"
   resolved "https://registry.yarnpkg.com/util/-/util-0.10.3.tgz#7afb1afe50805246489e3db7fe0ed379336ac0f9"