diff --git a/Jenkinsfile b/Jenkinsfile
index 18a2532f8236797c5f074eadcd9b6c160a9ef22a..5951a3a73926717b32d52927973e298a09cff5c6 100644
--- a/Jenkinsfile
+++ b/Jenkinsfile
@@ -1,3 +1,19 @@
+import groovy.json.JsonOutput
+
+// From https://issues.jenkins-ci.org/browse/JENKINS-44231
+
+// Given arbitrary string returns a strongly escaped shell string literal.
+// I.e. it will be in single quotes which turns off interpolation of $(...), etc.
+// E.g.: 1'2\3\'4 5"6 (groovy string) -> '1'\''2\3\'\''4 5"6' (groovy string which can be safely pasted into shell command).
+def shellString(s) {
+  // Replace ' with '\'' (https://unix.stackexchange.com/a/187654/260156). Then enclose with '...'.
+  // 1) Why not replace \ with \\? Because '...' does not treat backslashes in a special way.
+  // 2) And why not use ANSI-C quoting? I.e. we could replace ' with \'
+  // and enclose using $'...' (https://stackoverflow.com/a/8254156/4839573).
+  // Because ANSI-C quoting is not yet supported by Dash (default shell in Ubuntu & Debian) (https://unix.stackexchange.com/a/371873).
+  '\'' + s.replace('\'', '\'\\\'\'') + '\''
+}
+
 pipeline {
   agent any
 
@@ -6,9 +22,41 @@ pipeline {
   }
 
   stages {
+    stage('pre-build') {
+      steps {
+        sh 'rm -rf ./build ./tmp'
+      }
+    }
+
     stage('build') {
       steps {
-        build 'reticulum'
+        script {
+          def baseAssetsPath = env.BASE_ASSETS_PATH
+          def assetBundleServer = env.ASSET_BUNDLE_SERVER
+          def targetS3Url = env.TARGET_S3_URL
+          def smokeURL = env.SMOKE_URL
+          def slackURL = env.SLACK_URL
+
+          def habCommand = "sudo /usr/bin/hab-docker-studio -k mozillareality run /bin/bash scripts/hab-build-and-push.sh ${baseAssetsPath} ${assetBundleServer} ${targetS3Url} ${env.BUILD_NUMBER} ${env.GIT_COMMIT}"
+          sh "/usr/bin/script --return -c ${shellString(habCommand)} /dev/null"
+
+          def gitMessage = sh(returnStdout: true, script: "git log -n 1 --pretty=format:'[%an] %s'").trim()
+          def gitSha = sh(returnStdout: true, script: "git log -n 1 --pretty=format:'%h'").trim()
+          def text = (
+            "*<http://localhost:8080/job/${env.JOB_NAME}/${env.BUILD_NUMBER}|#${env.BUILD_NUMBER}>* *${env.JOB_NAME}* " +
+            "<https://github.com/mozilla/hubs/commit/$gitSha|$gitSha> " +
+            "Hubs: ```${gitSha} ${gitMessage}```\n" +
+            "<${smokeURL}?required_version=${env.BUILD_NUMBER}|Smoke Test> - to push:\n" +
+            "`/mr hubs deploy ${targetS3Url}`"
+          )
+          def payload = 'payload=' + JsonOutput.toJson([
+            text      : text,
+            channel   : "#mr-builds",
+            username  : "buildbot",
+            icon_emoji: ":gift:"
+          ])
+          sh "curl -X POST --data-urlencode ${shellString(payload)} ${slackURL}"
+        }
       }
     }
   }
diff --git a/package.json b/package.json
index cae88c47526aef2ed515ceaa4fda82d1a2812622..5c85d209240d059393329cb772c933150f6eb150 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,7 @@
   },
   "scripts": {
     "postinstall": "node ./scripts/postinstall.js",
-    "start": "cross-env NODE_ENV=development webpack-dev-server",
+    "start": "cross-env NODE_ENV=development webpack-dev-server --mode=production",
     "build": "rimraf ./public && cross-env NODE_ENV=production webpack --mode=production",
     "doc": "node ./scripts/doc/build.js",
     "prettier": "prettier --write '*.js' 'src/**/*.js'",
@@ -26,7 +26,7 @@
     "aframe-input-mapping-component": "https://github.com/mozillareality/aframe-input-mapping-component#hubs/master",
     "aframe-motion-capture-components": "https://github.com/mozillareality/aframe-motion-capture-components#1ca616fa67b627e447b23b35a09da175d8387668",
     "aframe-physics-extras": "^0.1.3",
-    "aframe-physics-system": "github:donmccurdy/aframe-physics-system",
+    "aframe-physics-system": "https://github.com/mozillareality/aframe-physics-system#hubs/master",
     "aframe-rounded": "^1.0.3",
     "aframe-slice9-component": "^1.0.0",
     "aframe-teleport-controls": "https://github.com/mozillareality/aframe-teleport-controls#hubs/master",
@@ -34,16 +34,13 @@
     "classnames": "^2.2.5",
     "copy-to-clipboard": "^3.0.8",
     "copy-webpack-plugin": "^4.5.1",
+    "deepmerge": "^2.1.1",
     "detect-browser": "^2.1.0",
-    "device-detect": "^1.0.7",
     "event-target-shim": "^3.0.1",
     "form-urlencoded": "^2.0.4",
     "jsonschema": "^1.2.2",
-    "mobile-detect": "^1.4.1",
-    "moment": "^2.22.0",
-    "moment-timezone": "^0.5.14",
     "moving-average": "^1.0.0",
-    "naf-janus-adapter": "^0.9.0",
+    "naf-janus-adapter": "^0.10.1",
     "networked-aframe": "https://github.com/mozillareality/networked-aframe#mr-social-client/master",
     "nipplejs": "https://github.com/mozillareality/nipplejs#mr-social-client/master",
     "phoenix": "^1.3.0",
@@ -87,8 +84,10 @@
     "selfsigned": "^1.10.2",
     "shelljs": "^0.8.1",
     "style-loader": "^0.20.2",
+    "url-loader": "^1.0.1",
     "webpack": "^4.0.1",
     "webpack-cli": "^2.0.9",
-    "webpack-dev-server": "^3.0.0"
+    "webpack-dev-server": "^3.0.0",
+    "worker-loader": "^2.0.0"
   }
 }
diff --git a/scripts/bot/package.json b/scripts/bot/package.json
index 6cce5823eb1095573315d55ba19227ad718cca6a..d9072e158422a09738b3f266e96046b5e24c61bf 100644
--- a/scripts/bot/package.json
+++ b/scripts/bot/package.json
@@ -5,7 +5,6 @@
   "devDependencies": {
     "docopt": "^0.6.2",
     "puppeteer": "1.1.0",
-    "query-string": "^5.0.1",
-    "serve": "^6.5.6"
+    "query-string": "^5.0.1"
   }
 }
diff --git a/scripts/bot/run-bot.sh b/scripts/bot/run-bot.sh
deleted file mode 100755
index aa693b25a11eca48bb83d4cc51115fdf6138f2eb..0000000000000000000000000000000000000000
--- a/scripts/bot/run-bot.sh
+++ /dev/null
@@ -1,24 +0,0 @@
-#!/usr/bin/env sh
-
-script_directory=$(dirname "$0")
-script_directory=$(realpath "$script_directory")
-
-echo 'Installing Hubs dependencies'
-cd $script_directory/../..
-yarn 
-echo 'Building Hubs'
-yarn build > /dev/null
-
-cd $script_directory
-echo 'Installing bot dependencies'
-yarn 
-echo 'Running Hubs'
-yarn serve --ssl --port 8080 ../../public &
-echo 'Running bots'
-if [ "$1" != "" ]; then
-  node run-bot.js --room $1
-else
-  node run-bot.js
-fi
-
-trap 'kill $(jobs -pr)' SIGINT SIGTERM EXIT
diff --git a/scripts/bot/yarn.lock b/scripts/bot/yarn.lock
index 3d46cd1ee8b87b01aed47b770f6ccf67ac822dab..2a00417d6e3c8f03d8a2e1661aa0a25655511164 100644
--- a/scripts/bot/yarn.lock
+++ b/scripts/bot/yarn.lock
@@ -2,98 +2,20 @@
 # yarn lockfile v1
 
 
-accepts@~1.3.4:
-  version "1.3.5"
-  resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.5.tgz#eb777df6011723a3b14e8a72c0805c8e86746bd2"
-  dependencies:
-    mime-types "~2.1.18"
-    negotiator "0.6.1"
-
-address@^1.0.1:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/address/-/address-1.0.3.tgz#b5f50631f8d6cec8bd20c963963afb55e06cbce9"
-
 agent-base@^4.1.0:
   version "4.2.0"
   resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.2.0.tgz#9838b5c3392b962bad031e6a4c5e1024abec45ce"
   dependencies:
     es6-promisify "^5.0.0"
 
-align-text@^0.1.1, align-text@^0.1.3:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/align-text/-/align-text-0.1.4.tgz#0cd90a561093f35d0a99256c22b7069433fad117"
-  dependencies:
-    kind-of "^3.0.2"
-    longest "^1.0.1"
-    repeat-string "^1.5.2"
-
-amdefine@>=0.0.4:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5"
-
-ansi-align@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-2.0.0.tgz#c36aeccba563b89ceb556f3690f0b1d9e3547f7f"
-  dependencies:
-    string-width "^2.0.0"
-
-ansi-regex@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998"
-
-ansi-styles@^3.2.1:
-  version "3.2.1"
-  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
-  dependencies:
-    color-convert "^1.9.0"
-
-arch@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/arch/-/arch-2.1.0.tgz#3613aa46149064b3c1f0607919bf1d4786e82889"
-
-args@4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/args/-/args-4.0.0.tgz#5ca24cdba43d4b17111c56616f5f2e9d91933954"
-  dependencies:
-    camelcase "5.0.0"
-    chalk "2.3.2"
-    leven "2.1.0"
-    mri "1.1.0"
-
 async-limiter@~1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.0.tgz#78faed8c3d074ab81f22b4e985d79e8738f720f8"
 
-async@^1.4.0:
-  version "1.5.2"
-  resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a"
-
 balanced-match@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
 
-basic-auth@2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/basic-auth/-/basic-auth-2.0.0.tgz#015db3f353e02e56377755f962742e8981e7bbba"
-  dependencies:
-    safe-buffer "5.1.1"
-
-bluebird@3.5.1:
-  version "3.5.1"
-  resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz#d9551f9de98f1fcda1e683d17ee91a0602ee2eb9"
-
-boxen@1.3.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b"
-  dependencies:
-    ansi-align "^2.0.0"
-    camelcase "^4.0.0"
-    chalk "^2.0.1"
-    cli-boxes "^1.0.0"
-    string-width "^2.0.0"
-    term-size "^1.2.0"
-    widest-line "^2.0.0"
-
 brace-expansion@^1.1.7:
   version "1.1.11"
   resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
@@ -101,100 +23,6 @@ brace-expansion@^1.1.7:
     balanced-match "^1.0.0"
     concat-map "0.0.1"
 
-bytes@3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048"
-
-camelcase@5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.0.0.tgz#03295527d58bd3cd4aa75363f35b2e8d97be2f42"
-
-camelcase@^1.0.2:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-1.2.1.tgz#9bb5304d2e0b56698b2c758b08a3eaa9daa58a39"
-
-camelcase@^4.0.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd"
-
-center-align@^0.1.1:
-  version "0.1.3"
-  resolved "https://registry.yarnpkg.com/center-align/-/center-align-0.1.3.tgz#aa0d32629b6ee972200411cbd4461c907bc2b7ad"
-  dependencies:
-    align-text "^0.1.3"
-    lazy-cache "^1.0.3"
-
-chalk@2.3.2:
-  version "2.3.2"
-  resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.3.2.tgz#250dc96b07491bfd601e648d66ddf5f60c7a5c65"
-  dependencies:
-    ansi-styles "^3.2.1"
-    escape-string-regexp "^1.0.5"
-    supports-color "^5.3.0"
-
-chalk@2.4.0:
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.0.tgz#a060a297a6b57e15b61ca63ce84995daa0fe6e52"
-  dependencies:
-    ansi-styles "^3.2.1"
-    escape-string-regexp "^1.0.5"
-    supports-color "^5.3.0"
-
-chalk@^2.0.1:
-  version "2.4.1"
-  resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.1.tgz#18c49ab16a037b6eb0152cc83e3471338215b66e"
-  dependencies:
-    ansi-styles "^3.2.1"
-    escape-string-regexp "^1.0.5"
-    supports-color "^5.3.0"
-
-cli-boxes@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-1.0.0.tgz#4fa917c3e59c94a004cd61f8ee509da651687143"
-
-clipboardy@1.2.3:
-  version "1.2.3"
-  resolved "https://registry.yarnpkg.com/clipboardy/-/clipboardy-1.2.3.tgz#0526361bf78724c1f20be248d428e365433c07ef"
-  dependencies:
-    arch "^2.1.0"
-    execa "^0.8.0"
-
-cliui@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/cliui/-/cliui-2.1.0.tgz#4b475760ff80264c762c3a1719032e91c7fea0d1"
-  dependencies:
-    center-align "^0.1.1"
-    right-align "^0.1.1"
-    wordwrap "0.0.2"
-
-color-convert@^1.9.0:
-  version "1.9.1"
-  resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.1.tgz#c1261107aeb2f294ebffec9ed9ecad529a6097ed"
-  dependencies:
-    color-name "^1.1.1"
-
-color-name@^1.1.1:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
-
-compressible@~2.0.13:
-  version "2.0.13"
-  resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.13.tgz#0d1020ab924b2fdb4d6279875c7d6daba6baa7a9"
-  dependencies:
-    mime-db ">= 1.33.0 < 2"
-
-compression@^1.6.2:
-  version "1.7.2"
-  resolved "http://registry.npmjs.org/compression/-/compression-1.7.2.tgz#aaffbcd6aaf854b44ebb280353d5ad1651f59a69"
-  dependencies:
-    accepts "~1.3.4"
-    bytes "3.0.0"
-    compressible "~2.0.13"
-    debug "2.6.9"
-    on-headers "~1.0.1"
-    safe-buffer "5.1.1"
-    vary "~1.1.2"
-
 concat-map@0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
@@ -207,27 +35,11 @@ concat-stream@1.6.0:
     readable-stream "^2.2.2"
     typedarray "^0.0.6"
 
-content-type@1.0.4:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
-
 core-util-is@~1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
 
-cross-spawn@^5.0.1:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449"
-  dependencies:
-    lru-cache "^4.0.1"
-    shebang-command "^1.2.0"
-    which "^1.2.9"
-
-dargs@5.1.0:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/dargs/-/dargs-5.1.0.tgz#ec7ea50c78564cd36c9d5ec18f66329fade27829"
-
-debug@2.6.9, debug@^2.6.0, debug@^2.6.8:
+debug@2.6.9, debug@^2.6.8:
   version "2.6.9"
   resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
   dependencies:
@@ -239,49 +51,14 @@ debug@^3.1.0:
   dependencies:
     ms "2.0.0"
 
-decamelize@^1.0.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
-
 decode-uri-component@^0.2.0:
   version "0.2.0"
   resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545"
 
-deep-extend@^0.5.1:
-  version "0.5.1"
-  resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.5.1.tgz#b894a9dd90d3023fbf1c55a394fb858eb2066f1f"
-
-depd@1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.1.tgz#5783b4e1c459f06fa5ca27f991f3d06e7a310359"
-
-depd@~1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
-
-destroy@~1.0.4:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80"
-
-detect-port@1.2.2:
-  version "1.2.2"
-  resolved "https://registry.yarnpkg.com/detect-port/-/detect-port-1.2.2.tgz#57a44533632d8bc74ad255676866ca43f96c7469"
-  dependencies:
-    address "^1.0.1"
-    debug "^2.6.0"
-
 docopt@^0.6.2:
   version "0.6.2"
   resolved "https://registry.yarnpkg.com/docopt/-/docopt-0.6.2.tgz#b28e9e2220da5ec49f7ea5bb24a47787405eeb11"
 
-ee-first@1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
-
-encodeurl@~1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
-
 es6-promise@^4.0.3:
   version "4.2.4"
   resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.4.tgz#dc4221c2b16518760bd8c39a52d8f356fc00ed29"
@@ -292,42 +69,6 @@ es6-promisify@^5.0.0:
   dependencies:
     es6-promise "^4.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.5:
-  version "1.0.5"
-  resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
-
-etag@~1.8.1:
-  version "1.8.1"
-  resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
-
-execa@^0.7.0:
-  version "0.7.0"
-  resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777"
-  dependencies:
-    cross-spawn "^5.0.1"
-    get-stream "^3.0.0"
-    is-stream "^1.1.0"
-    npm-run-path "^2.0.0"
-    p-finally "^1.0.0"
-    signal-exit "^3.0.0"
-    strip-eof "^1.0.0"
-
-execa@^0.8.0:
-  version "0.8.0"
-  resolved "https://registry.yarnpkg.com/execa/-/execa-0.8.0.tgz#d8d76bbc1b55217ed190fd6dd49d3c774ecfc8da"
-  dependencies:
-    cross-spawn "^5.0.1"
-    get-stream "^3.0.0"
-    is-stream "^1.1.0"
-    npm-run-path "^2.0.0"
-    p-finally "^1.0.0"
-    signal-exit "^3.0.0"
-    strip-eof "^1.0.0"
-
 extract-zip@^1.6.5:
   version "1.6.6"
   resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-1.6.6.tgz#1290ede8d20d0872b429fd3f351ca128ec5ef85c"
@@ -343,30 +84,10 @@ fd-slicer@~1.0.1:
   dependencies:
     pend "~1.2.0"
 
-filesize@3.6.1:
-  version "3.6.1"
-  resolved "https://registry.yarnpkg.com/filesize/-/filesize-3.6.1.tgz#090bb3ee01b6f801a8a8be99d31710b3422bb317"
-
-fresh@0.5.2:
-  version "0.5.2"
-  resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
-
-fs-extra@5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-5.0.0.tgz#414d0110cdd06705734d055652c5411260c31abd"
-  dependencies:
-    graceful-fs "^4.1.2"
-    jsonfile "^4.0.0"
-    universalify "^0.1.0"
-
 fs.realpath@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
 
-get-stream@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14"
-
 glob@^7.0.5:
   version "7.1.2"
   resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15"
@@ -378,42 +99,6 @@ glob@^7.0.5:
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
-graceful-fs@^4.1.2, graceful-fs@^4.1.6:
-  version "4.1.11"
-  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658"
-
-handlebars@4.0.11:
-  version "4.0.11"
-  resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.0.11.tgz#630a35dfe0294bc281edae6ffc5d329fc7982dcc"
-  dependencies:
-    async "^1.4.0"
-    optimist "^0.6.1"
-    source-map "^0.4.4"
-  optionalDependencies:
-    uglify-js "^2.6"
-
-has-flag@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
-
-http-errors@1.6.2:
-  version "1.6.2"
-  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.2.tgz#0a002cc85707192a7e7946ceedc11155f60ec736"
-  dependencies:
-    depd "1.1.1"
-    inherits "2.0.3"
-    setprototypeof "1.0.3"
-    statuses ">= 1.3.1 < 2"
-
-http-errors@~1.6.2:
-  version "1.6.3"
-  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d"
-  dependencies:
-    depd "~1.1.2"
-    inherits "2.0.3"
-    setprototypeof "1.1.0"
-    statuses ">= 1.4.0 < 2"
-
 https-proxy-agent@^2.1.0:
   version "2.2.1"
   resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-2.2.1.tgz#51552970fa04d723e04c56d04178c3f92592bbc0"
@@ -421,10 +106,6 @@ https-proxy-agent@^2.1.0:
     agent-base "^4.1.0"
     debug "^3.1.0"
 
-iconv-lite@0.4.19:
-  version "0.4.19"
-  resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b"
-
 inflight@^1.0.4:
   version "1.0.6"
   resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
@@ -432,102 +113,14 @@ inflight@^1.0.4:
     once "^1.3.0"
     wrappy "1"
 
-inherits@2, inherits@2.0.3, inherits@^2.0.3, inherits@~2.0.3:
+inherits@2, inherits@^2.0.3, inherits@~2.0.3:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
 
-ini@~1.3.0:
-  version "1.3.5"
-  resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
-
-ip@1.1.5:
-  version "1.1.5"
-  resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a"
-
-is-buffer@^1.1.5:
-  version "1.1.6"
-  resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be"
-
-is-fullwidth-code-point@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f"
-
-is-stream@1.1.0, is-stream@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
-
-is-wsl@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-1.1.0.tgz#1f16e4aa22b04d1336b66188a66af3c600c3a66d"
-
 isarray@~1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
 
-isexe@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
-
-jsonfile@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb"
-  optionalDependencies:
-    graceful-fs "^4.1.6"
-
-kind-of@^3.0.2:
-  version "3.2.2"
-  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"
-  dependencies:
-    is-buffer "^1.1.5"
-
-lazy-cache@^1.0.3:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e"
-
-leven@2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/leven/-/leven-2.1.0.tgz#c2e7a9f772094dee9d34202ae8acce4687875580"
-
-longest@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097"
-
-lru-cache@^4.0.1:
-  version "4.1.3"
-  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.3.tgz#a1175cf3496dfc8436c156c334b4955992bce69c"
-  dependencies:
-    pseudomap "^1.0.2"
-    yallist "^2.1.2"
-
-micro-compress@1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/micro-compress/-/micro-compress-1.0.0.tgz#53f5a80b4ad0320ca165a559b6e3df145d4f704f"
-  dependencies:
-    compression "^1.6.2"
-
-micro@9.1.4:
-  version "9.1.4"
-  resolved "https://registry.yarnpkg.com/micro/-/micro-9.1.4.tgz#dbe655f34bb3390509898ddf3fda12348f5cbaa9"
-  dependencies:
-    content-type "1.0.4"
-    is-stream "1.1.0"
-    mri "1.1.0"
-    raw-body "2.3.2"
-
-"mime-db@>= 1.33.0 < 2", mime-db@~1.33.0:
-  version "1.33.0"
-  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.33.0.tgz#a3492050a5cb9b63450541e39d9788d2272783db"
-
-mime-types@2.1.18, mime-types@~2.1.18:
-  version "2.1.18"
-  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.18.tgz#6f323f60a83d11146f831ff11fd66e2fe5503bb8"
-  dependencies:
-    mime-db "~1.33.0"
-
-mime@1.4.1:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6"
-
 mime@^1.3.4:
   version "1.6.0"
   resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
@@ -542,109 +135,34 @@ minimist@0.0.8:
   version "0.0.8"
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
 
-minimist@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284"
-
-minimist@~0.0.1:
-  version "0.0.10"
-  resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf"
-
 mkdirp@0.5.0:
   version "0.5.0"
   resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.0.tgz#1d73076a6df986cd9344e15e71fcc05a4c9abf12"
   dependencies:
     minimist "0.0.8"
 
-mri@1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/mri/-/mri-1.1.0.tgz#5c0a3f29c8ccffbbb1ec941dcec09d71fa32f36a"
-
 ms@2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
 
-negotiator@0.6.1:
-  version "0.6.1"
-  resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9"
-
-node-version@1.1.3:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/node-version/-/node-version-1.1.3.tgz#1081c87cce6d2dbbd61d0e51e28c287782678496"
-
-npm-run-path@^2.0.0:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f"
-  dependencies:
-    path-key "^2.0.0"
-
 object-assign@^4.1.0:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
 
-on-finished@~2.3.0:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
-  dependencies:
-    ee-first "1.1.1"
-
-on-headers@~1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.1.tgz#928f5d0f470d49342651ea6794b0857c100693f7"
-
 once@^1.3.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
   dependencies:
     wrappy "1"
 
-openssl-self-signed-certificate@1.1.6:
-  version "1.1.6"
-  resolved "https://registry.yarnpkg.com/openssl-self-signed-certificate/-/openssl-self-signed-certificate-1.1.6.tgz#9d3a4776b1a57e9847350392114ad2f915a83dd4"
-
-opn@5.3.0:
-  version "5.3.0"
-  resolved "https://registry.yarnpkg.com/opn/-/opn-5.3.0.tgz#64871565c863875f052cfdf53d3e3cb5adb53b1c"
-  dependencies:
-    is-wsl "^1.1.0"
-
-optimist@^0.6.1:
-  version "0.6.1"
-  resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686"
-  dependencies:
-    minimist "~0.0.1"
-    wordwrap "~0.0.2"
-
-p-finally@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae"
-
 path-is-absolute@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
 
-path-is-inside@1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53"
-
-path-key@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40"
-
-path-type@3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f"
-  dependencies:
-    pify "^3.0.0"
-
 pend@~1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
 
-pify@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176"
-
 process-nextick-args@~2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.0.tgz#a37d732f4271b4ab1ad070d35508e8290788ffaa"
@@ -657,10 +175,6 @@ proxy-from-env@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.0.0.tgz#33c50398f70ea7eb96d21f7b817630a55791c7ee"
 
-pseudomap@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
-
 puppeteer@1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-1.1.0.tgz#97fbc2fbbf9ab659e7e202a68ac1ba54b8bc0a25"
@@ -682,28 +196,6 @@ query-string@^5.0.1:
     object-assign "^4.1.0"
     strict-uri-encode "^1.0.0"
 
-range-parser@~1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.0.tgz#f49be6b487894ddc40dcc94a322f611092e00d5e"
-
-raw-body@2.3.2:
-  version "2.3.2"
-  resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.3.2.tgz#bcd60c77d3eb93cde0050295c3f379389bc88f89"
-  dependencies:
-    bytes "3.0.0"
-    http-errors "1.6.2"
-    iconv-lite "0.4.19"
-    unpipe "1.0.0"
-
-rc@^1.0.1, rc@^1.1.6:
-  version "1.2.7"
-  resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.7.tgz#8a10ca30d588d00464360372b890d06dacd02297"
-  dependencies:
-    deep-extend "^0.5.1"
-    ini "~1.3.0"
-    minimist "^1.2.0"
-    strip-json-comments "~2.0.1"
-
 readable-stream@^2.2.2:
   version "2.3.6"
   resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf"
@@ -716,239 +208,38 @@ readable-stream@^2.2.2:
     string_decoder "~1.1.1"
     util-deprecate "~1.0.1"
 
-registry-auth-token@3.3.2:
-  version "3.3.2"
-  resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-3.3.2.tgz#851fd49038eecb586911115af845260eec983f20"
-  dependencies:
-    rc "^1.1.6"
-    safe-buffer "^5.0.1"
-
-registry-url@3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-3.1.0.tgz#3d4ef870f73dde1d77f0cf9a381432444e174942"
-  dependencies:
-    rc "^1.0.1"
-
-repeat-string@^1.5.2:
-  version "1.6.1"
-  resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637"
-
-right-align@^0.1.1:
-  version "0.1.3"
-  resolved "https://registry.yarnpkg.com/right-align/-/right-align-0.1.3.tgz#61339b722fe6a3515689210d24e14c96148613ef"
-  dependencies:
-    align-text "^0.1.1"
-
 rimraf@^2.6.1:
   version "2.6.2"
   resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.2.tgz#2ed8150d24a16ea8651e6d6ef0f47c4158ce7a36"
   dependencies:
     glob "^7.0.5"
 
-safe-buffer@5.1.1:
-  version "5.1.1"
-  resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853"
-
-safe-buffer@^5.0.1, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
+safe-buffer@~5.1.0, safe-buffer@~5.1.1:
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
 
-send@0.16.2:
-  version "0.16.2"
-  resolved "https://registry.yarnpkg.com/send/-/send-0.16.2.tgz#6ecca1e0f8c156d141597559848df64730a6bbc1"
-  dependencies:
-    debug "2.6.9"
-    depd "~1.1.2"
-    destroy "~1.0.4"
-    encodeurl "~1.0.2"
-    escape-html "~1.0.3"
-    etag "~1.8.1"
-    fresh "0.5.2"
-    http-errors "~1.6.2"
-    mime "1.4.1"
-    ms "2.0.0"
-    on-finished "~2.3.0"
-    range-parser "~1.2.0"
-    statuses "~1.4.0"
-
-serve@^6.5.6:
-  version "6.5.6"
-  resolved "https://registry.yarnpkg.com/serve/-/serve-6.5.6.tgz#579136688f80f6bf4a618ca8e8cba10dfb4d95e3"
-  dependencies:
-    args "4.0.0"
-    basic-auth "2.0.0"
-    bluebird "3.5.1"
-    boxen "1.3.0"
-    chalk "2.4.0"
-    clipboardy "1.2.3"
-    dargs "5.1.0"
-    detect-port "1.2.2"
-    filesize "3.6.1"
-    fs-extra "5.0.0"
-    handlebars "4.0.11"
-    ip "1.1.5"
-    micro "9.1.4"
-    micro-compress "1.0.0"
-    mime-types "2.1.18"
-    node-version "1.1.3"
-    openssl-self-signed-certificate "1.1.6"
-    opn "5.3.0"
-    path-is-inside "1.0.2"
-    path-type "3.0.0"
-    send "0.16.2"
-    update-check "1.3.2"
-
-setprototypeof@1.0.3:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.0.3.tgz#66567e37043eeb4f04d91bd658c0cbefb55b8e04"
-
-setprototypeof@1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656"
-
-shebang-command@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"
-  dependencies:
-    shebang-regex "^1.0.0"
-
-shebang-regex@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3"
-
-signal-exit@^3.0.0:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"
-
-source-map@^0.4.4:
-  version "0.4.4"
-  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.4.4.tgz#eba4f5da9c0dc999de68032d8b4f76173652036b"
-  dependencies:
-    amdefine ">=0.0.4"
-
-source-map@~0.5.1:
-  version "0.5.7"
-  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
-
-"statuses@>= 1.3.1 < 2", "statuses@>= 1.4.0 < 2":
-  version "1.5.0"
-  resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
-
-statuses@~1.4.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.4.0.tgz#bb73d446da2796106efcc1b601a253d6c46bd087"
-
 strict-uri-encode@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713"
 
-string-width@^2.0.0, string-width@^2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e"
-  dependencies:
-    is-fullwidth-code-point "^2.0.0"
-    strip-ansi "^4.0.0"
-
 string_decoder@~1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
   dependencies:
     safe-buffer "~5.1.0"
 
-strip-ansi@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f"
-  dependencies:
-    ansi-regex "^3.0.0"
-
-strip-eof@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf"
-
-strip-json-comments@~2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
-
-supports-color@^5.3.0:
-  version "5.4.0"
-  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.4.0.tgz#1c6b337402c2137605efe19f10fec390f6faab54"
-  dependencies:
-    has-flag "^3.0.0"
-
-term-size@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/term-size/-/term-size-1.2.0.tgz#458b83887f288fc56d6fffbfad262e26638efa69"
-  dependencies:
-    execa "^0.7.0"
-
 typedarray@^0.0.6:
   version "0.0.6"
   resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
 
-uglify-js@^2.6:
-  version "2.8.29"
-  resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.8.29.tgz#29c5733148057bb4e1f75df35b7a9cb72e6a59dd"
-  dependencies:
-    source-map "~0.5.1"
-    yargs "~3.10.0"
-  optionalDependencies:
-    uglify-to-browserify "~1.0.0"
-
-uglify-to-browserify@~1.0.0:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz#6e0924d6bda6b5afe349e39a6d632850a0f882b7"
-
 ultron@~1.1.0:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.1.1.tgz#9fe1536a10a664a65266a1e3ccf85fd36302bc9c"
 
-universalify@^0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.1.tgz#fa71badd4437af4c148841e3b3b165f9e9e590b7"
-
-unpipe@1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
-
-update-check@1.3.2:
-  version "1.3.2"
-  resolved "https://registry.yarnpkg.com/update-check/-/update-check-1.3.2.tgz#460f9e9ab24820367f3edbeb4d4142d9936ff171"
-  dependencies:
-    registry-auth-token "3.3.2"
-    registry-url "3.1.0"
-
 util-deprecate@~1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
 
-vary@~1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
-
-which@^1.2.9:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/which/-/which-1.3.0.tgz#ff04bdfc010ee547d780bec38e1ac1c2777d253a"
-  dependencies:
-    isexe "^2.0.0"
-
-widest-line@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-2.0.0.tgz#0142a4e8a243f8882c0233aa0e0281aa76152273"
-  dependencies:
-    string-width "^2.1.1"
-
-window-size@0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.1.0.tgz#5438cd2ea93b202efa3a19fe8887aee7c94f9c9d"
-
-wordwrap@0.0.2:
-  version "0.0.2"
-  resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.2.tgz#b79669bb42ecb409f83d583cad52ca17eaa1643f"
-
-wordwrap@~0.0.2:
-  version "0.0.3"
-  resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107"
-
 wrappy@1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
@@ -961,19 +252,6 @@ ws@^3.0.0:
     safe-buffer "~5.1.0"
     ultron "~1.1.0"
 
-yallist@^2.1.2:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
-
-yargs@~3.10.0:
-  version "3.10.0"
-  resolved "https://registry.yarnpkg.com/yargs/-/yargs-3.10.0.tgz#f7ee7bd857dd7c1d2d38c0e74efbd681d1431fd1"
-  dependencies:
-    camelcase "^1.0.2"
-    cliui "^2.1.0"
-    decamelize "^1.0.0"
-    window-size "0.1.0"
-
 yauzl@2.4.1:
   version "2.4.1"
   resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.4.1.tgz#9528f442dab1b2284e58b4379bb194e22e0c4005"
diff --git a/scripts/build_local_reticulum.sh b/scripts/build_local_reticulum.sh
deleted file mode 100755
index 9a19f9f202b688b27213b0478f9e1a14e67d2620..0000000000000000000000000000000000000000
--- a/scripts/build_local_reticulum.sh
+++ /dev/null
@@ -1,7 +0,0 @@
-#!/usr/bin/env bash
-
-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://hubs.local:4000/ yarn build -- --output-path ../reticulum/priv/static 
diff --git a/scripts/hab-build-and-push.sh b/scripts/hab-build-and-push.sh
new file mode 100755
index 0000000000000000000000000000000000000000..f890d0e053a95860c5d758a5288c451ac7bc89f1
--- /dev/null
+++ b/scripts/hab-build-and-push.sh
@@ -0,0 +1,38 @@
+#!/bin/bash
+
+export BASE_ASSETS_PATH=$1
+export ASSET_BUNDLE_SERVER=$2
+export TARGET_S3_URL=$3
+export BUILD_NUMBER=$4
+export GIT_COMMIT=$5
+export BUILD_VERSION="${BUILD_NUMBER} (${GIT_COMMIT})"
+
+# To build + push to S3 run:
+# hab studio run "bash scripts/hab-build-and-push.sh"
+
+# On exit, need to make all files writable so CI can clean on next build
+trap 'chmod -R a+rw .' EXIT
+
+DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+
+pushd "$DIR/.."
+
+mkdir -p .yarn
+mkdir -p node_modules
+mkdir -p build
+
+# Yarn expects /usr/local/share
+# https://github.com/yarnpkg/yarn/issues/4628
+mkdir -p /usr/local/share
+
+rm /usr/bin/env
+ln -s "$(hab pkg path core/coreutils)/bin/env" /usr/bin/env
+hab pkg install -b core/coreutils core/bash core/node core/yarn core/git core/aws-cli
+
+yarn install --cache-folder .yarn
+GENERATE_SMOKE_TESTS=true yarn build --output-path build
+mkdir build/pages
+mv build/*.html build/pages
+
+aws s3 sync --acl public-read --cache-control "max-age=31556926" build/assets "$TARGET_S3_URL/assets"
+aws s3 sync --acl public-read --cache-control "no-cache" --delete build/pages "$TARGET_S3_URL/pages/latest"
diff --git a/scripts/run-local-reticulum.sh b/scripts/run-local-reticulum.sh
new file mode 100644
index 0000000000000000000000000000000000000000..976d858cd8dd7adea49ae587995759c431420182
--- /dev/null
+++ b/scripts/run-local-reticulum.sh
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+BASE_ASSETS_PATH=https://hubs.local:8080/ DEV_RETICULUM_SERVER=hubs.local:4000 yarn start
diff --git a/src/assets/images/media-error.gif b/src/assets/images/media-error.gif
new file mode 100644
index 0000000000000000000000000000000000000000..825bb624f1770088bf75eaa515d2e612e61adae9
Binary files /dev/null and b/src/assets/images/media-error.gif differ
diff --git a/src/assets/stylesheets/2d-hud.scss b/src/assets/stylesheets/2d-hud.scss
index 263569b58b04e599868e601cd0c960cee543c843..475e4615923a6e1359984b1c6ea9be72e9242947 100644
--- a/src/assets/stylesheets/2d-hud.scss
+++ b/src/assets/stylesheets/2d-hud.scss
@@ -1,3 +1,5 @@
+@import 'shared';
+
 :local(.container) {
   position: absolute;
   top: 10px;
@@ -37,9 +39,24 @@
   width: 40px;
   height: 40px;
   background-size: 100%;
+  border-radius: 50%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
   cursor: pointer;
 }
 
+:local(.addMediaButton) {
+  position: absolute;
+  top: 90px;
+  background-color: #404040;
+}
+
+:local(.iconButton.small) {
+  width: 30px;
+  height: 30px;
+}
+
 :local(.iconButton.large) {
   width: 80px;
   height: 80px;
diff --git a/src/assets/stylesheets/info-dialog.scss b/src/assets/stylesheets/info-dialog.scss
index 690db56c83a0fee301b0bb95952f6a1622ec9140..58b2259fc71f0097d8f2b032b1fcc9bfa5305251 100644
--- a/src/assets/stylesheets/info-dialog.scss
+++ b/src/assets/stylesheets/info-dialog.scss
@@ -79,7 +79,7 @@
   }
 }
 
-.invite-form {
+.invite-form, .add-media-form {
   display: flex;
   flex-direction: column;
   align-items: center;
diff --git a/src/assets/translations.data.json b/src/assets/translations.data.json
index 79d11599544849a83f9982617dafddd3bb6c268a..1f8ae6a1b0986b0576ad3dd567b1fc8bb96c1d86 100644
--- a/src/assets/translations.data.json
+++ b/src/assets/translations.data.json
@@ -40,6 +40,7 @@
     "exit.subtitle.closed": "This room is no longer available.",
     "exit.subtitle.full": "This room is full, please try again later.",
     "exit.subtitle.connect_error": "Unable to connect to this room, please try again later.",
+    "exit.subtitle.version_mismatch": "The version you deployed is not available yet. Your browser will refresh in 5 seconds.",
     "autoexit.title": "Auto-ending session in ",
     "autoexit.title_units": " seconds",
     "autoexit.subtitle": "You have started another session.",
diff --git a/src/components/auto-box-collider.js b/src/components/auto-box-collider.js
new file mode 100644
index 0000000000000000000000000000000000000000..220ad92ed83ba506aa63f70dc2ee24e087ce61db
--- /dev/null
+++ b/src/components/auto-box-collider.js
@@ -0,0 +1,49 @@
+AFRAME.registerComponent("auto-box-collider", {
+  schema: {
+    resize: { default: false },
+    resizeLength: { default: 0.5 }
+  },
+
+  init() {
+    this.onLoaded = this.onLoaded.bind(this);
+    this.el.addEventListener("model-loaded", this.onLoaded);
+  },
+
+  remove() {
+    this.el.removeEventListener("model-loaded", this.onLoaded);
+  },
+
+  onLoaded() {
+    const rotation = this.el.object3D.rotation.clone();
+    this.el.object3D.rotation.set(0, 0, 0);
+    const { min, max } = new THREE.Box3().setFromObject(this.el.object3DMap.mesh);
+    const halfExtents = new THREE.Vector3()
+      .addVectors(min.clone().negate(), max)
+      .multiplyScalar(0.5 / this.el.object3D.scale.x);
+    this.el.setAttribute("shape", {
+      shape: "box",
+      halfExtents: halfExtents,
+      offset: new THREE.Vector3(0, halfExtents.y, 0)
+    });
+    if (this.data.resize) {
+      this.resize(min, max);
+    }
+    this.el.object3D.rotation.copy(rotation);
+    this.el.removeAttribute("auto-box-collider");
+  },
+
+  // Adjust the scale such that the object fits within a box of a specified size.
+  resize(min, max) {
+    const dX = Math.abs(max.x - min.x);
+    const dY = Math.abs(max.y - min.y);
+    const dZ = Math.abs(max.z - min.z);
+    const lengthOfLongestComponent = Math.max(dX, dY, dZ);
+    const correctiveFactor = this.data.resizeLength / lengthOfLongestComponent;
+    const scale = this.el.object3D.scale;
+    this.el.setAttribute("scale", {
+      x: scale.x * correctiveFactor,
+      y: scale.y * correctiveFactor,
+      z: scale.z * correctiveFactor
+    });
+  }
+});
diff --git a/src/components/auto-scale-cannon-physics-body.js b/src/components/auto-scale-cannon-physics-body.js
new file mode 100644
index 0000000000000000000000000000000000000000..1633dffef17c70db520a7e8edb7ef3c819734970
--- /dev/null
+++ b/src/components/auto-scale-cannon-physics-body.js
@@ -0,0 +1,31 @@
+function almostEquals(epsilon, u, v) {
+  return Math.abs(u.x - v.x) < epsilon && Math.abs(u.y - v.y) < epsilon && Math.abs(u.z - v.z) < epsilon;
+}
+
+AFRAME.registerComponent("auto-scale-cannon-physics-body", {
+  dependencies: ["body"],
+  schema: {
+    equalityEpsilon: { default: 0.001 },
+    debounceDelay: { default: 100 }
+  },
+
+  init() {
+    this.body = this.el.components["body"];
+    this.prevScale = this.el.object3D.scale.clone();
+    this.nextUpdateTime = -1;
+  },
+
+  tick(t) {
+    const scale = this.el.object3D.scale;
+    // Note: This only checks if the LOCAL scale of the object3D changes.
+    if (!almostEquals(this.data.equalityEpsilon, scale, this.prevScale)) {
+      this.prevScale.copy(scale);
+      this.nextUpdateTime = t + this.data.debounceDelay;
+    }
+
+    if (this.nextUpdateTime > 0 && t > this.nextUpdateTime) {
+      this.nextUpdateTime = -1;
+      this.body.updateCannonScale();
+    }
+  }
+});
diff --git a/src/components/character-controller.js b/src/components/character-controller.js
index f32debe666107ccfb6144f94e7ca5afe3f9f4168..02322f1a48a7bd6c1f1c137f5ba3e248ff885f5c 100644
--- a/src/components/character-controller.js
+++ b/src/components/character-controller.js
@@ -18,8 +18,8 @@ AFRAME.registerComponent("character-controller", {
   },
 
   init: function() {
-    this.navGroup;
-    this.navNode;
+    this.navGroup = null;
+    this.navNode = null;
     this.velocity = new THREE.Vector3(0, 0, 0);
     this.accelerationInput = new THREE.Vector3(0, 0, 0);
     this.pendingSnapRotationMatrix = new THREE.Matrix4();
@@ -160,7 +160,7 @@ AFRAME.registerComponent("character-controller", {
   setPositionOnNavMesh: function(startPosition, endPosition, object3D) {
     const nav = this.el.sceneEl.systems.nav;
     if (nav.navMesh) {
-      if (!this.navGroup) {
+      if (this.navGroup == null) {
         this.navGroup = nav.getGroup(endPosition);
       }
       this.navNode = this.navNode || nav.getNode(endPosition, this.navGroup);
diff --git a/src/components/cursor-controller.js b/src/components/cursor-controller.js
index e0dda6f0bce57127c09ef5580bf84a497ad2caf8..38be846de6e7f68fbf937be2937459a445099555 100644
--- a/src/components/cursor-controller.js
+++ b/src/components/cursor-controller.js
@@ -65,7 +65,7 @@ AFRAME.registerComponent("cursor-controller", {
         const rayObject = this.data.rayObject.object3D;
         rayObjectRotation.setFromRotationMatrix(rayObject.matrixWorld);
         this.direction
-          .set(0, 0, 1)
+          .set(0, 0, -1)
           .applyQuaternion(rayObjectRotation)
           .normalize();
         this.origin.setFromMatrixPosition(rayObject.matrixWorld);
diff --git a/src/components/gltf-model-plus.js b/src/components/gltf-model-plus.js
index 55d62b10e997a943d39c6e593cb35256c67b0e6c..b1195922467eb57dd6b536e688b147c164e502ea 100644
--- a/src/components/gltf-model-plus.js
+++ b/src/components/gltf-model-plus.js
@@ -74,16 +74,34 @@ function cloneGltf(gltf) {
   return clone;
 }
 
-const inflateEntities = function(parentEl, node, gltfPath) {
-  // setObject3D mutates the node's parent, so we have to copy
-  const children = node.children.slice(0);
+/// Walks the tree of three.js objects starting at the given node, using the GLTF data
+/// and template data to construct A-Frame entities and components when necessary.
+/// (It's unnecessary to construct entities for subtrees that have no component data
+/// or templates associated with any of their nodes.)
+///
+/// Returns the A-Frame entity associated with the given node, if one was constructed.
+const inflateEntities = function(node, templates, gltfPath) {
+  // inflate subtrees first so that we can determine whether or not this node needs to be inflated
+  const childEntities = [];
+  const children = node.children.slice(0); // setObject3D mutates the node's parent, so we have to copy
+  for (const child of children) {
+    const el = inflateEntities(child, templates, gltfPath);
+    if (el) {
+      childEntities.push(el);
+    }
+  }
+
+  const nodeHasBehavior = node.userData.components || node.name in templates;
+  if (!nodeHasBehavior && !childEntities.length) {
+    return null; // we don't need an entity for this node
+  }
 
   const el = document.createElement("a-entity");
+  el.append.apply(el, childEntities);
 
   // Remove invalid CSS class name characters.
   const className = (node.name || node.uuid).replace(/[^\w-]/g, "");
   el.classList.add(className);
-  parentEl.appendChild(el);
 
   // AFRAME rotation component expects rotations in YXZ, convert it
   if (node.rotation.order !== "YXZ") {
@@ -138,15 +156,11 @@ const inflateEntities = function(parentEl, node, gltfPath) {
     }
   }
 
-  children.forEach(childNode => {
-    inflateEntities(el, childNode, gltfPath);
-  });
-
   return el;
 };
 
-function attachTemplate(root, { selector, templateRoot }) {
-  const targetEls = root.querySelectorAll(selector);
+function attachTemplate(root, name, templateRoot) {
+  const targetEls = root.querySelectorAll("." + name);
   for (const el of targetEls) {
     const root = templateRoot.cloneNode(true);
     // Merge root element attributes with the target element
@@ -168,30 +182,15 @@ function nextTick() {
 }
 
 function cachedLoadGLTF(src, preferredTechnique, onProgress) {
-  return new Promise((resolve, reject) => {
-    // Load the gltf model from the cache if it exists.
-    if (GLTFCache[src]) {
-      // Use a cloned copy of the cached model.
-      resolve(cloneGltf(GLTFCache[src]));
-    } else {
-      // Otherwise load the new gltf model.
+  // Load the gltf model from the cache if it exists.
+  if (!GLTFCache[src]) {
+    GLTFCache[src] = new Promise((resolve, reject) => {
       const gltfLoader = new THREE.GLTFLoader();
       gltfLoader.preferredTechnique = preferredTechnique;
-
-      gltfLoader.load(
-        src,
-        model => {
-          if (!GLTFCache[src]) {
-            // Store a cloned copy of the gltf model.
-            GLTFCache[src] = cloneGltf(model);
-          }
-          resolve(model);
-        },
-        onProgress,
-        reject
-      );
-    }
-  });
+      gltfLoader.load(src, resolve, onProgress, reject);
+    });
+  }
+  return GLTFCache[src].then(cloneGltf);
 }
 
 /**
@@ -203,11 +202,11 @@ function cachedLoadGLTF(src, preferredTechnique, onProgress) {
 AFRAME.registerComponent("gltf-model-plus", {
   schema: {
     src: { type: "string" },
-    inflate: { default: false },
-    preferredTechnique: { default: AFRAME.utils.device.isMobile() ? "KHR_materials_unlit" : "pbrMetallicRoughness" }
+    inflate: { default: false }
   },
 
   init() {
+    this.preferredTechnique = AFRAME.utils.device.isMobile() ? "KHR_materials_unlit" : "pbrMetallicRoughness";
     this.loadTemplates();
   },
 
@@ -216,13 +215,11 @@ AFRAME.registerComponent("gltf-model-plus", {
   },
 
   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)
-      })
-    );
+    this.templates = {};
+    this.el.querySelectorAll(":scope > template").forEach(templateEl => {
+      const root = document.importNode(templateEl.firstElementChild || templateEl.content.firstElementChild, true);
+      this.templates[templateEl.getAttribute("data-name")] = root;
+    });
   },
 
   async applySrc(src) {
@@ -245,7 +242,7 @@ AFRAME.registerComponent("gltf-model-plus", {
       }
 
       const gltfPath = THREE.LoaderUtils.extractUrlBase(src);
-      const model = await cachedLoadGLTF(src, this.data.preferredTechnique);
+      const model = await cachedLoadGLTF(src, this.preferredTechnique);
 
       // If we started loading something else already
       // TODO: there should be a way to cancel loading instead
@@ -260,17 +257,20 @@ AFRAME.registerComponent("gltf-model-plus", {
       this.el.setObject3D("mesh", this.model);
 
       if (this.data.inflate) {
-        this.inflatedEl = inflateEntities(this.el, this.model, gltfPath);
+        this.inflatedEl = inflateEntities(this.model, this.templates, gltfPath);
+        this.el.appendChild(this.inflatedEl);
         // 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));
+        for (const name in this.templates) {
+          attachTemplate(this.el, name, this.templates[name]);
+        }
       }
 
       this.el.emit("model-loaded", { format: "gltf", model: this.model });
     } catch (e) {
-      console.error("Failed to load glTF model", e.message, this);
+      console.error("Failed to load glTF model", e, this);
       this.el.emit("model-error", { format: "gltf", src });
     }
   },
diff --git a/src/components/ik-controller.js b/src/components/ik-controller.js
index 3b2f3026a56ce64afb9460c6c6de0f37ccc6d969..1bf9e71de656aa9b4972cdf59e534af78599493b 100644
--- a/src/components/ik-controller.js
+++ b/src/components/ik-controller.js
@@ -39,14 +39,14 @@ function findIKRoot(entity) {
  */
 AFRAME.registerComponent("ik-controller", {
   schema: {
-    leftEye: { type: "string", default: ".LeftEye" },
-    rightEye: { type: "string", default: ".RightEye" },
-    head: { type: "string", default: ".Head" },
-    neck: { type: "string", default: ".Neck" },
-    leftHand: { type: "string", default: ".LeftHand" },
-    rightHand: { type: "string", default: ".RightHand" },
-    chest: { type: "string", default: ".Chest" },
-    hips: { type: "string", default: ".Hips" },
+    leftEye: { type: "string", default: "LeftEye" },
+    rightEye: { type: "string", default: "RightEye" },
+    head: { type: "string", default: "Head" },
+    neck: { type: "string", default: "Neck" },
+    leftHand: { type: "string", default: "LeftHand" },
+    rightHand: { type: "string", default: "RightHand" },
+    chest: { type: "string", default: "Chest" },
+    hips: { type: "string", default: "Hips" },
     rotationSpeed: { default: 5 }
   },
 
@@ -86,46 +86,46 @@ AFRAME.registerComponent("ik-controller", {
 
   update(oldData) {
     if (this.data.leftEye !== oldData.leftEye) {
-      this.leftEye = this.el.querySelector(this.data.leftEye);
+      this.leftEye = this.el.object3D.getObjectByName(this.data.leftEye);
     }
 
     if (this.data.rightEye !== oldData.rightEye) {
-      this.rightEye = this.el.querySelector(this.data.rightEye);
+      this.rightEye = this.el.object3D.getObjectByName(this.data.rightEye);
     }
 
     if (this.data.head !== oldData.head) {
-      this.head = this.el.querySelector(this.data.head);
+      this.head = this.el.object3D.getObjectByName(this.data.head);
     }
 
     if (this.data.neck !== oldData.neck) {
-      this.neck = this.el.querySelector(this.data.neck);
+      this.neck = this.el.object3D.getObjectByName(this.data.neck);
     }
 
     if (this.data.leftHand !== oldData.leftHand) {
-      this.leftHand = this.el.querySelector(this.data.leftHand);
+      this.leftHand = this.el.object3D.getObjectByName(this.data.leftHand);
     }
 
     if (this.data.rightHand !== oldData.rightHand) {
-      this.rightHand = this.el.querySelector(this.data.rightHand);
+      this.rightHand = this.el.object3D.getObjectByName(this.data.rightHand);
     }
 
     if (this.data.chest !== oldData.chest) {
-      this.chest = this.el.querySelector(this.data.chest);
+      this.chest = this.el.object3D.getObjectByName(this.data.chest);
     }
 
     if (this.data.hips !== oldData.hips) {
-      this.hips = this.el.querySelector(this.data.hips);
+      this.hips = this.el.object3D.getObjectByName(this.data.hips);
     }
 
     // Set middleEye's position to be right in the middle of the left and right eyes.
-    this.middleEyePosition.addVectors(this.leftEye.object3D.position, this.rightEye.object3D.position);
+    this.middleEyePosition.addVectors(this.leftEye.position, this.rightEye.position);
     this.middleEyePosition.divideScalar(2);
     this.middleEyeMatrix.makeTranslation(this.middleEyePosition.x, this.middleEyePosition.y, this.middleEyePosition.z);
     this.invMiddleEyeToHead = this.middleEyeMatrix.getInverse(this.middleEyeMatrix);
 
     this.invHipsToHeadVector
-      .addVectors(this.chest.object3D.position, this.neck.object3D.position)
-      .add(this.head.object3D.position)
+      .addVectors(this.chest.position, this.neck.position)
+      .add(this.head.position)
       .negate();
   },
 
@@ -162,35 +162,30 @@ AFRAME.registerComponent("ik-controller", {
 
     // Then position the hips such that the head is aligned with headTransform
     // (which positions middleEye in line with the hmd)
-    hips.object3D.position.setFromMatrixPosition(headTransform).add(invHipsToHeadVector);
+    hips.position.setFromMatrixPosition(headTransform).add(invHipsToHeadVector);
 
     // Animate the hip rotation to follow the Y rotation of the camera with some damping.
     cameraYRotation.setFromRotationMatrix(cameraForward, "YXZ");
     cameraYRotation.x = 0;
     cameraYRotation.z = 0;
     cameraYQuaternion.setFromEuler(cameraYRotation);
-    Quaternion.slerp(
-      hips.object3D.quaternion,
-      cameraYQuaternion,
-      hips.object3D.quaternion,
-      this.data.rotationSpeed * dt / 1000
-    );
+    Quaternion.slerp(hips.quaternion, cameraYQuaternion, hips.quaternion, this.data.rotationSpeed * dt / 1000);
 
     // Take the head orientation computed from the hmd, remove the Y rotation already applied to it by the hips,
     // and apply it to the head
-    invHipsQuaternion.copy(hips.object3D.quaternion).inverse();
-    head.object3D.quaternion.setFromRotationMatrix(headTransform).premultiply(invHipsQuaternion);
+    invHipsQuaternion.copy(hips.quaternion).inverse();
+    head.quaternion.setFromRotationMatrix(headTransform).premultiply(invHipsQuaternion);
 
-    hips.object3D.updateMatrix();
-    rootToChest.multiplyMatrices(hips.object3D.matrix, chest.object3D.matrix);
+    hips.updateMatrix();
+    rootToChest.multiplyMatrices(hips.matrix, chest.matrix);
     invRootToChest.getInverse(rootToChest);
 
     this.updateHand(this.hands.left, leftHand, leftController);
     this.updateHand(this.hands.right, rightHand, rightController);
   },
 
-  updateHand(handState, hand, controller) {
-    const handObject3D = hand.object3D;
+  updateHand(handState, handObject3D, controller) {
+    const hand = handObject3D.el;
     const handMatrix = handObject3D.matrix;
     const controllerObject3D = controller.object3D;
 
diff --git a/src/components/image-plus.js b/src/components/image-plus.js
new file mode 100644
index 0000000000000000000000000000000000000000..744020534a6d9f5f6924ea12084572d4eb325109
--- /dev/null
+++ b/src/components/image-plus.js
@@ -0,0 +1,248 @@
+import GIFWorker from "../workers/gifparsing.worker.js";
+import errorImageSrc from "!!url-loader!../assets/images/media-error.gif";
+
+class GIFTexture extends THREE.Texture {
+  constructor(frames, delays, disposals) {
+    super(document.createElement("canvas"));
+    this.image.width = frames[0].width;
+    this.image.height = frames[0].height;
+
+    this._ctx = this.image.getContext("2d");
+
+    this.generateMipmaps = false;
+    this.isVideoTexture = true;
+    this.minFilter = THREE.NearestFilter;
+
+    this.frames = frames;
+    this.delays = delays;
+    this.disposals = disposals;
+
+    this.frame = 0;
+    this.frameStartTime = Date.now();
+  }
+
+  update() {
+    if (!this.frames || !this.delays || !this.disposals) return;
+    const now = Date.now();
+    if (now - this.frameStartTime > this.delays[this.frame]) {
+      if (this.disposals[this.frame] === 2) {
+        this._ctx.clearRect(0, 0, this.image.width, this.image.width);
+      }
+      this.frame = (this.frame + 1) % this.frames.length;
+      this.frameStartTime = now;
+      this._ctx.drawImage(this.frames[this.frame], 0, 0, this.image.width, this.image.height);
+      this.needsUpdate = true;
+    }
+  }
+}
+
+/**
+ * Create video element to be used as a texture.
+ *
+ * @param {string} src - Url to a video file.
+ * @returns {Element} Video element.
+ */
+function createVideoEl(src) {
+  const videoEl = document.createElement("video");
+  videoEl.setAttribute("playsinline", "");
+  videoEl.setAttribute("webkit-playsinline", "");
+  videoEl.autoplay = true;
+  videoEl.loop = true;
+  videoEl.crossOrigin = "anonymous";
+  videoEl.src = src;
+  return videoEl;
+}
+
+const textureLoader = new THREE.TextureLoader();
+textureLoader.setCrossOrigin("anonymous");
+
+const textureCache = new Map();
+
+const errorImage = new Image();
+errorImage.src = errorImageSrc;
+const errorTexture = new THREE.Texture(errorImage);
+errorTexture.magFilter = THREE.NearestFilter;
+errorImage.onload = () => {
+  errorTexture.needsUpdate = true;
+};
+
+AFRAME.registerComponent("image-plus", {
+  dependencies: ["geometry"],
+
+  schema: {
+    src: { type: "string" },
+    contentType: { type: "string" }
+  },
+
+  _fit(w, h) {
+    const ratio = (h || 1.0) / (w || 1.0);
+    const geo = this.el.geometry;
+    let width, height;
+    if (geo && geo.width) {
+      if (geo.height && ratio > 1) {
+        width = geo.width / ratio;
+      } else {
+        height = geo.height * ratio;
+      }
+    } else if (geo && geo.height) {
+      width = geo.width / ratio;
+    } else {
+      width = Math.min(1.0, 1.0 / ratio);
+      height = Math.min(1.0, ratio);
+    }
+    this.el.setAttribute("geometry", { width, height });
+    this.el.setAttribute("shape", {
+      shape: "box",
+      halfExtents: {
+        x: width / 2,
+        y: height / 2,
+        z: 0.05
+      }
+    });
+  },
+
+  init() {
+    const material = new THREE.MeshBasicMaterial();
+    material.side = THREE.DoubleSide;
+    material.transparent = true;
+    this.el.getObject3D("mesh").material = material;
+  },
+
+  remove() {
+    const material = this.el.getObject3D("mesh").material;
+    const texture = material.map;
+
+    if (texture === errorTexture) return;
+
+    const url = texture.image.src;
+    const cacheItem = textureCache.get(url);
+    cacheItem.count--;
+    if (cacheItem.count <= 0) {
+      // Unload the video element to prevent it from continuing to play in the background
+      if (texture.image instanceof HTMLVideoElement) {
+        const video = texture.image;
+        video.pause();
+        video.src = "";
+        video.load();
+      }
+
+      texture.dispose();
+
+      // THREE never lets go of material refs, long running PR HERE https://github.com/mrdoob/three.js/pull/12464
+      // Mitigate the damage a bit by at least breaking the image ref so Image/Video elements can be freed
+      // TODO: If/when THREE gets fixed, we should be able to safely remove this
+      delete texture.image;
+
+      textureCache.delete(url);
+    }
+  },
+
+  async loadGIF(url) {
+    return new Promise((resolve, reject) => {
+      // TODO: pool workers
+      const worker = new GIFWorker();
+      worker.onmessage = e => {
+        const [success, frames, delays, disposals] = e.data;
+        if (!success) {
+          reject(`error loading gif: ${e.data[1]}`);
+          return;
+        }
+
+        let loadCnt = 0;
+        for (let i = 0; i < frames.length; i++) {
+          const img = new Image();
+          img.onload = e => {
+            loadCnt++;
+            frames[i] = e.target;
+            if (loadCnt === frames.length) {
+              const texture = new GIFTexture(frames, delays, disposals);
+              texture.image.src = url;
+              resolve(texture);
+            }
+          };
+          img.src = frames[i];
+        }
+      };
+      fetch(url, { mode: "cors" })
+        .then(r => r.arrayBuffer())
+        .then(rawImageData => {
+          worker.postMessage(rawImageData, [rawImageData]);
+        })
+        .catch(reject);
+    });
+  },
+
+  loadVideo(url) {
+    return new Promise((resolve, reject) => {
+      const videoEl = createVideoEl(url);
+
+      const texture = new THREE.VideoTexture(videoEl);
+      texture.minFilter = THREE.LinearFilter;
+      videoEl.addEventListener("loadedmetadata", () => resolve(texture), { once: true });
+      videoEl.onerror = reject;
+
+      // If iOS and video is HLS, do some hacks.
+      if (
+        this.el.sceneEl.isIOS &&
+        AFRAME.utils.material.isHLS(
+          videoEl.src || videoEl.getAttribute("src"),
+          videoEl.type || videoEl.getAttribute("type")
+        )
+      ) {
+        // Actually BGRA. Tell shader to correct later.
+        texture.format = THREE.RGBAFormat;
+        texture.needsCorrectionBGRA = true;
+        // Apparently needed for HLS. Tell shader to correct later.
+        texture.flipY = false;
+        texture.needsCorrectionFlipY = true;
+      }
+    });
+  },
+
+  loadImage(url) {
+    return new Promise((resolve, reject) => {
+      textureLoader.load(url, resolve, null, function(xhr) {
+        reject(`'${url}' could not be fetched (Error code: ${xhr.status}; Response: ${xhr.statusText})`);
+      });
+    });
+  },
+
+  async update() {
+    let texture;
+    try {
+      const url = this.data.src;
+      const contentType = this.data.contentType;
+      if (!url) {
+        return;
+      }
+
+      if (textureCache.has(url)) {
+        const cacheItem = textureCache.get(url);
+        texture = cacheItem.texture;
+        cacheItem.count++;
+      } else {
+        if (url === "error") {
+          texture = errorTexture;
+        } else if (contentType === "image/gif") {
+          texture = await this.loadGIF(url);
+        } else if (contentType.startsWith("image/")) {
+          texture = await this.loadImage(url);
+        } else if (contentType.startsWith("video")) {
+          texture = await this.loadVideo(url);
+        } else {
+          throw new Error(`Unknown centent type: ${contentType}`);
+        }
+
+        textureCache.set(url, { count: 1, texture });
+      }
+    } catch (e) {
+      console.error("Error loading media", this.data.src, e);
+      texture = errorTexture;
+    }
+
+    const material = this.el.getObject3D("mesh").material;
+    material.map = texture;
+    material.needsUpdate = true;
+    this._fit(texture.image.videoWidth || texture.image.width, texture.image.videoHeight || texture.image.height);
+  }
+});
diff --git a/src/components/input-configurator.js b/src/components/input-configurator.js
index 1609b20c7b163504d3dcc46390767bf14c91a09e..df36b38d8c4bb81a2f8fd98824cbd3ecf4ef7f15 100644
--- a/src/components/input-configurator.js
+++ b/src/components/input-configurator.js
@@ -69,7 +69,7 @@ AFRAME.registerComponent("input-configurator", {
     this.eventHandlers = [];
     this.actionEventHandler = null;
     if (this.lookOnMobile) {
-      this.lookOnMobile.el.removeComponent("look-on-mobile");
+      this.lookOnMobile.el.removeAttribute("look-on-mobile");
       this.lookOnMobile = null;
     }
     this.cursorRequiresManagement = false;
diff --git a/src/components/networked-counter.js b/src/components/networked-counter.js
index be9725cf6a50efb822370ab5d4b7196a6bcce3c4..6902658b03eef05429e2de1fdcd8cf48debec090 100644
--- a/src/components/networked-counter.js
+++ b/src/components/networked-counter.js
@@ -6,130 +6,90 @@
 AFRAME.registerComponent("networked-counter", {
   schema: {
     max: { default: 3 },
-    ttl: { default: 120 },
+    ttl: { default: 0 },
     grab_event: { type: "string", default: "grab-start" },
     release_event: { type: "string", default: "grab-end" }
   },
 
-  init: function() {
-    this.count = 0;
-    this.queue = {};
-    this.timeouts = {};
+  init() {
+    this.registeredEls = new Map();
   },
 
-  remove: function() {
-    for (const id in this.queue) {
-      if (this.queue.hasOwnProperty(id)) {
-        const item = this.queue[id];
-        item.el.removeEventListener(this.data.grab_event, item.onGrabHandler);
-        item.el.removeEventListener(this.data.release_event, item.onReleaseHandler);
-      }
-    }
-
-    for (const id in this.timeouts) {
-      this._removeTimeout(id);
-    }
+  remove() {
+    this.registeredEls.forEach(({ onGrabHandler, onReleaseHandler, timeout }, el) => {
+      el.removeEventListener(this.data.grab_event, onGrabHandler);
+      el.removeEventListener(this.data.release_event, onReleaseHandler);
+      clearTimeout(timeout);
+    });
+    this.registeredEls.clear();
   },
 
-  register: function(networkedEl) {
-    if (this.data.max <= 0) {
-      return;
-    }
-
-    const id = NAF.utils.getNetworkId(networkedEl);
-    if (id && this.queue.hasOwnProperty(id)) {
-      return;
-    }
+  register(el) {
+    if (this.data.max <= 0 || this.registeredEls.has(el)) return;
 
-    const now = Date.now();
-    const grabEventListener = this._onGrabbed.bind(this, id);
-    const releaseEventListener = this._onReleased.bind(this, id);
+    const grabEventListener = this._onGrabbed.bind(this, el);
+    const releaseEventListener = this._onReleased.bind(this, el);
 
-    this.queue[id] = {
-      ts: now,
-      el: networkedEl,
+    this.registeredEls.set(el, {
+      ts: Date.now(),
       onGrabHandler: grabEventListener,
       onReleaseHandler: releaseEventListener
-    };
+    });
 
-    networkedEl.addEventListener(this.data.grab_event, grabEventListener);
-    networkedEl.addEventListener(this.data.release_event, releaseEventListener);
+    el.addEventListener(this.data.grab_event, grabEventListener);
+    el.addEventListener(this.data.release_event, releaseEventListener);
 
-    this.count++;
-
-    if (!this._isCurrentlyGrabbed(id)) {
-      this._addTimeout(id);
+    if (!el.is("grabbed")) {
+      this._startTimer(el);
     }
 
     this._destroyOldest();
   },
 
-  deregister: function(networkedEl) {
-    const id = NAF.utils.getNetworkId(networkedEl);
-    if (id && this.queue.hasOwnProperty(id)) {
-      const item = this.queue[id];
-      networkedEl.removeEventListener(this.data.grab_event, item.onGrabHandler);
-      networkedEl.removeEventListener(this.data.release_event, item.onReleaseHandler);
-
-      delete this.queue[id];
-
-      this._removeTimeout(id);
-      delete this.timeouts[id];
-
-      this.count--;
+  deregister(el) {
+    if (this.registeredEls.has(el)) {
+      const { onGrabHandler, onReleaseHandler, timeout } = this.registeredEls.get(el);
+      el.removeEventListener(this.data.grab_event, onGrabHandler);
+      el.removeEventListener(this.data.release_event, onReleaseHandler);
+      clearTimeout(timeout);
+      this.registeredEls.delete(el);
     }
   },
 
-  _onGrabbed: function(id) {
-    this._removeTimeout(id);
+  _onGrabbed(el) {
+    clearTimeout(this.registeredEls.get(el).timeout);
   },
 
-  _onReleased: function(id) {
-    this._removeTimeout(id);
-    this._addTimeout(id);
-    this.queue[id].ts = Date.now();
+  _onReleased(el) {
+    this._startTimer(el);
+    this.registeredEls.get(el).ts = Date.now();
   },
 
-  _destroyOldest: function() {
-    if (this.count > this.data.max) {
-      let oldest = null,
-        ts = Number.MAX_VALUE;
-      for (const id in this.queue) {
-        if (this.queue.hasOwnProperty(id)) {
-          if (this.queue[id].ts < ts && !this._isCurrentlyGrabbed(id)) {
-            oldest = this.queue[id];
-            ts = this.queue[id].ts;
-          }
+  _destroyOldest() {
+    if (this.registeredEls.size > this.data.max) {
+      let oldestEl = null,
+        minTs = Number.MAX_VALUE;
+      this.registeredEls.forEach(({ ts }, el) => {
+        if (ts < minTs && !el.is("grabbed")) {
+          oldestEl = el;
+          minTs = ts;
         }
-      }
-      if (ts > 0) {
-        this.deregister(oldest.el);
-        this._destroy(oldest.el);
-      }
+      });
+      this._destroy(oldestEl);
     }
   },
 
-  _isCurrentlyGrabbed: function(id) {
-    const networkedEl = this.queue[id].el;
-    return networkedEl.is("grabbed");
-  },
-
-  _addTimeout: function(id) {
-    const timeout = this.data.ttl * 1000;
-    this.timeouts[id] = setTimeout(() => {
-      const el = this.queue[id].el;
-      this.deregister(el);
+  _startTimer(el) {
+    if (!this.data.ttl) return;
+    clearTimeout(this.registeredEls.get(el).timeout);
+    this.registeredEls.get(el).timeout = setTimeout(() => {
       this._destroy(el);
-    }, timeout);
-  },
-
-  _removeTimeout: function(id) {
-    if (this.timeouts.hasOwnProperty(id)) {
-      clearTimeout(this.timeouts[id]);
-    }
+    }, this.data.ttl * 1000);
   },
 
-  _destroy: function(networkedEl) {
-    networkedEl.parentNode.removeChild(networkedEl);
+  _destroy(el) {
+    // networked-interactable's remvoe will also call deregister, but it will happen async so we do it here as well.
+    this.deregister(el);
+    el.parentNode.removeChild(el);
   }
 });
diff --git a/src/components/offset-relative-to.js b/src/components/offset-relative-to.js
index 3cd45b942ed4573ee9b588a467de456af801f62f..23920e099a94fd50373f6804bf8c02d8cae0910f 100644
--- a/src/components/offset-relative-to.js
+++ b/src/components/offset-relative-to.js
@@ -12,16 +12,41 @@ AFRAME.registerComponent("offset-relative-to", {
     },
     on: {
       type: "string"
+    },
+    selfDestruct: {
+      default: false
     }
   },
   init() {
-    this.updateOffset();
-    this.el.sceneEl.addEventListener(this.data.on, this.updateOffset.bind(this));
+    this.updateOffset = this.updateOffset.bind(this);
+    if (this.data.on) {
+      this.el.sceneEl.addEventListener(this.data.on, this.updateOffset);
+    } else {
+      this.updateOffset();
+    }
   },
-  updateOffset() {
-    const offsetVector = new THREE.Vector3().copy(this.data.offset);
-    this.data.target.object3D.localToWorld(offsetVector);
-    this.el.setAttribute("position", offsetVector);
-    this.data.target.object3D.getWorldQuaternion(this.el.object3D.quaternion);
-  }
+
+  updateOffset: (function() {
+    const offsetVector = new THREE.Vector3();
+    return function() {
+      const obj = this.el.object3D;
+      const target = this.data.target.object3D;
+      offsetVector.copy(this.data.offset);
+      target.localToWorld(offsetVector);
+      if (obj.parent) {
+        obj.parent.worldToLocal(offsetVector);
+      }
+      obj.position.copy(offsetVector);
+      // TODO: Hack here to deal with the fact that the rotation component mutates ordering, and we network rotation without sending ordering information
+      // See https://github.com/networked-aframe/networked-aframe/issues/134
+      obj.rotation.order = "YXZ";
+      target.getWorldQuaternion(obj.quaternion);
+      if (this.data.selfDestruct) {
+        if (this.data.on) {
+          this.el.sceneEl.removeEventListener(this.data.on, this.updateOffset);
+        }
+        this.el.removeAttribute("offset-relative-to");
+      }
+    };
+  })()
 });
diff --git a/src/components/position-at-box-shape-border.js b/src/components/position-at-box-shape-border.js
new file mode 100644
index 0000000000000000000000000000000000000000..59776bf6de0c2f12f2b46e40720a4287dee49c9b
--- /dev/null
+++ b/src/components/position-at-box-shape-border.js
@@ -0,0 +1,89 @@
+const PI = Math.PI;
+const HALF_PI = PI / 2;
+const THREE_HALF_PI = 3 * PI / 2;
+const right = new THREE.Vector3(1, 0, 0);
+const forward = new THREE.Vector3(0, 0, 1);
+const left = new THREE.Vector3(-1, 0, 0);
+const back = new THREE.Vector3(0, 0, -1);
+const dirs = {
+  left: {
+    dir: left,
+    rotation: THREE_HALF_PI,
+    halfExtent: "x"
+  },
+  right: {
+    dir: right,
+    rotation: HALF_PI,
+    halfExtent: "x"
+  },
+  forward: {
+    dir: forward,
+    rotation: 0,
+    halfExtent: "z"
+  },
+  back: {
+    dir: back,
+    rotation: PI,
+    halfExtent: "z"
+  }
+};
+
+AFRAME.registerComponent("position-at-box-shape-border", {
+  schema: {
+    target: { type: "string" },
+    dirs: { default: ["left", "right", "forward", "back"] }
+  },
+
+  init() {
+    this.cam = this.el.sceneEl.camera.el.object3D;
+  },
+
+  update() {
+    this.dirs = this.data.dirs.map(d => dirs[d]);
+  },
+
+  tick: (function() {
+    const camWorldPos = new THREE.Vector3();
+    const targetPosition = new THREE.Vector3();
+    const pointOnBoxFace = new THREE.Vector3();
+    return function() {
+      if (!this.shape) {
+        this.shape = this.el.components["shape"];
+        if (!this.shape) return;
+      }
+      if (!this.target) {
+        this.target = this.el.querySelector(this.data.target).object3D;
+        if (!this.target) return;
+      }
+      const halfExtents = this.shape.data.halfExtents;
+      this.cam.getWorldPosition(camWorldPos);
+
+      let minSquareDistance = Infinity;
+      let targetDir = this.dirs[0].dir;
+      let targetHalfExtent = halfExtents[this.dirs[0].halfExtent];
+      let targetRotation = this.dirs[0].rotation;
+
+      for (let i = 0; i < this.dirs.length; i++) {
+        const dir = this.dirs[i].dir;
+        const halfExtent = halfExtents[this.dirs[i].halfExtent];
+        pointOnBoxFace.copy(dir).multiplyScalar(halfExtent);
+        this.el.object3D.localToWorld(pointOnBoxFace);
+        const squareDistance = pointOnBoxFace.distanceToSquared(camWorldPos);
+        if (squareDistance < minSquareDistance) {
+          minSquareDistance = squareDistance;
+          targetDir = dir;
+          targetHalfExtent = halfExtent;
+          targetRotation = this.dirs[i].rotation;
+        }
+      }
+
+      this.target.position.copy(
+        targetPosition
+          .copy(targetDir)
+          .multiplyScalar(targetHalfExtent)
+          .add(this.shape.data.offset)
+      );
+      this.target.rotation.set(0, targetRotation, 0);
+    };
+  })()
+});
diff --git a/src/components/remove-networked-object-button.js b/src/components/remove-networked-object-button.js
new file mode 100644
index 0000000000000000000000000000000000000000..39ec27b754a2dfbb5476c9490ee40e3c30f12f79
--- /dev/null
+++ b/src/components/remove-networked-object-button.js
@@ -0,0 +1,18 @@
+AFRAME.registerComponent("remove-networked-object-button", {
+  init() {
+    this.onClick = () => {
+      this.targetEl.parentNode.removeChild(this.targetEl);
+    };
+    NAF.utils.getNetworkedEntity(this.el).then(networkedEl => {
+      this.targetEl = networkedEl;
+    });
+  },
+
+  play() {
+    this.el.addEventListener("click", this.onClick);
+  },
+
+  pause() {
+    this.el.removeEventListener("click", this.onClick);
+  }
+});
diff --git a/src/components/sticky-object.js b/src/components/sticky-object.js
new file mode 100644
index 0000000000000000000000000000000000000000..d415779078f6a0acb2d6975170cde39cbd80cf47
--- /dev/null
+++ b/src/components/sticky-object.js
@@ -0,0 +1,130 @@
+/* global THREE, CANNON, AFRAME */
+AFRAME.registerComponent("sticky-object", {
+  dependencies: ["body", "super-networked-interactable"],
+
+  schema: {
+    autoLockOnLoad: { default: false },
+    autoLockOnRelease: { default: false },
+    autoLockSpeedLimit: { default: 0.25 }
+  },
+
+  init() {
+    this._onGrab = this._onGrab.bind(this);
+    this._onRelease = this._onRelease.bind(this);
+    this._onBodyLoaded = this._onBodyLoaded.bind(this);
+  },
+
+  play() {
+    this.el.addEventListener("grab-start", this._onGrab);
+    this.el.addEventListener("grab-end", this._onRelease);
+    this.el.addEventListener("body-loaded", this._onBodyLoaded);
+  },
+
+  pause() {
+    this.el.removeEventListener("grab-start", this._onGrab);
+    this.el.removeEventListener("grab-end", this._onRelease);
+    this.el.removeEventListener("body-loaded", this._onBodyLoaded);
+  },
+
+  setLocked(locked) {
+    if (!NAF.utils.isMine(this.el)) return;
+
+    const mass = this.el.components["super-networked-interactable"].data.mass;
+    this.locked = locked;
+    this.el.body.type = locked ? window.CANNON.Body.STATIC : window.CANNON.Body.DYNAMIC;
+    this.el.setAttribute("body", {
+      mass: locked ? 0 : mass
+    });
+  },
+
+  _onBodyLoaded() {
+    if (this.data.autoLockOnLoad) {
+      this.setLocked(true);
+    }
+  },
+
+  _onRelease() {
+    if (
+      this.data.autoLockOnRelease &&
+      this.el.body.velocity.lengthSquared() < this.data.autoLockSpeedLimit * this.data.autoLockSpeedLimit
+    ) {
+      this.setLocked(true);
+    }
+  },
+
+  _onGrab() {
+    this.setLocked(false);
+  },
+
+  remove() {
+    if (this.stuckTo) {
+      const stuckTo = this.stuckTo;
+      delete this.stuckTo;
+      stuckTo._unstickObject();
+    }
+  }
+});
+
+AFRAME.registerComponent("sticky-object-zone", {
+  dependencies: ["physics"],
+  init() {
+    // TODO: position/rotation/impulse need to get updated if the sticky-object-zone moves
+    this.worldQuaternion = new THREE.Quaternion();
+    this.worldPosition = new THREE.Vector3();
+    this.el.object3D.getWorldQuaternion(this.worldQuaternion);
+    this.el.object3D.getWorldPosition(this.worldPosition);
+
+    const dir = new THREE.Vector3(0, 0, 5).applyQuaternion(this.el.object3D.quaternion);
+    this.bootImpulsePosition = new CANNON.Vec3(0, 0, 0);
+    this.bootImpulse = new CANNON.Vec3();
+    this.bootImpulse.copy(dir);
+
+    this._onCollisions = this._onCollisions.bind(this);
+    this.el.addEventListener("collisions", this._onCollisions);
+  },
+
+  remove() {
+    this.el.removeEventListener("collisions", this._onCollisions);
+  },
+
+  _onCollisions(e) {
+    e.detail.els.forEach(el => {
+      const stickyObject = el.components["sticky-object"];
+      if (!stickyObject) return;
+      this._setStuckObject(stickyObject);
+    });
+    if (this.stuckObject) {
+      e.detail.clearedEls.forEach(el => {
+        if (this.stuckObject && this.stuckObject.el === el) {
+          this._unstickObject();
+        }
+      });
+    }
+  },
+
+  _setStuckObject(stickyObject) {
+    stickyObject.setLocked(true);
+    stickyObject.el.object3D.position.copy(this.worldPosition);
+    stickyObject.el.object3D.quaternion.copy(this.worldQuaternion);
+    stickyObject.el.body.collisionResponse = false;
+    stickyObject.stuckTo = this;
+
+    if (this.stuckObject && NAF.utils.isMine(this.stuckObject.el)) {
+      const el = this.stuckObject.el;
+      this._unstickObject();
+      el.body.applyImpulse(this.bootImpulse, this.bootImpulsePosition);
+    }
+
+    this.stuckObject = stickyObject;
+  },
+
+  _unstickObject() {
+    // this condition will be false when dragging an object directly from one sticky zone to another
+    if (this.stuckObject.stuckTo === this) {
+      this.stuckObject.setLocked(false);
+      this.stuckObject.el.body.collisionResponse = true;
+      delete this.stuckObject.stuckTo;
+    }
+    delete this.stuckObject;
+  }
+});
diff --git a/src/components/super-networked-interactable.js b/src/components/super-networked-interactable.js
index 418b8e50f9b6d5b8f3eb7b89f4547e4dd23aee47..3b84bb7ac3b99421024113d527aa8a1ea54da725 100644
--- a/src/components/super-networked-interactable.js
+++ b/src/components/super-networked-interactable.js
@@ -24,17 +24,17 @@ AFRAME.registerComponent("super-networked-interactable", {
       }
     });
 
-    this.grabStartListener = this._onGrabStart.bind(this);
-    this.ownershipLostListener = this._onOwnershipLost.bind(this);
-    this.el.addEventListener("grab-start", this.grabStartListener);
-    this.el.addEventListener("ownership-lost", this.ownershipLostListener);
+    this._onGrabStart = this._onGrabStart.bind(this);
+    this._onOwnershipLost = this._onOwnershipLost.bind(this);
+    this.el.addEventListener("grab-start", this._onGrabStart);
+    this.el.addEventListener("ownership-lost", this._onOwnershipLost);
     this.system.addComponent(this);
   },
 
   remove: function() {
     this.counter.deregister(this.el);
-    this.el.removeEventListener("grab-start", this.grabStartListener);
-    this.el.removeEventListener("ownership-lost", this.ownershipLostListener);
+    this.el.removeEventListener("grab-start", this._onGrabStart);
+    this.el.removeEventListener("ownership-lost", this._onOwnershipLost);
     this.system.removeComponent(this);
   },
 
diff --git a/src/components/text-button.js b/src/components/text-button.js
index 67af3653dcc4c9e81b59aec376feab6a00eb7fe4..491b482a248099ae8a277ed5cb04dbb638df6f83 100644
--- a/src/components/text-button.js
+++ b/src/components/text-button.js
@@ -57,3 +57,12 @@ AFRAME.registerComponent("text-button", {
     this.textEl.setAttribute("text", "color", hovering ? this.data.textHoverColor : this.data.textColor);
   }
 });
+
+const noop = function() {};
+// TODO: this should ideally be fixed upstream somehow but its pretty tricky since text is just a geometry not a different type of Object3D, and Object3D is what handles raycast checks.
+AFRAME.registerComponent("text-raycast-hack", {
+  dependencies: ["text"],
+  init() {
+    this.el.getObject3D("text").raycast = noop;
+  }
+});
diff --git a/src/gltf-component-mappings.js b/src/gltf-component-mappings.js
index d036e0e6c14d308ad07f3458f5f937c3dccdfeda..0552e61c854ad7efe6ed5e97020b512ed5d0feac 100644
--- a/src/gltf-component-mappings.js
+++ b/src/gltf-component-mappings.js
@@ -23,6 +23,7 @@ AFRAME.GLTFModelPlus.registerComponent("shape", "shape");
 AFRAME.GLTFModelPlus.registerComponent("visible", "visible");
 AFRAME.GLTFModelPlus.registerComponent("spawn-point", "spawn-point");
 AFRAME.GLTFModelPlus.registerComponent("hoverable", "hoverable");
+AFRAME.GLTFModelPlus.registerComponent("sticky-zone", "sticky-zone");
 AFRAME.GLTFModelPlus.registerComponent("nav-mesh", "nav-mesh", (el, componentName, componentData, gltfPath) => {
   if (componentData.src) {
     componentData.src = resolveURL(componentData.src, gltfPath);
diff --git a/src/hub.html b/src/hub.html
index d53540bac7114ccc2dbf0b1e6ce883a703e1a5c6..2a379ab44607c2318ab7017aa00e12b45e982d50 100644
--- a/src/hub.html
+++ b/src/hub.html
@@ -40,15 +40,15 @@
         vr-mode-ui="enabled: false"
         pinch-to-move
         input-configurator="
-                  gazeCursorRayObject: #player-camera-reverse-z;
+                  gazeCursorRayObject: #player-camera;
                   cursorController: #cursor-controller;
                   gazeTeleporter: #gaze-teleport;
                   camera: #player-camera;
                   playerRig: #player-rig;
                   leftController: #player-left-controller;
-                  leftControllerRayObject: #player-left-controller-reverse-z;
+                  leftControllerRayObject: #player-left-controller;
                   rightController: #player-right-controller;
-                  rightControllerRayObject: #player-right-controller-reverse-z;"
+                  rightControllerRayObject: #player-right-controller;"
     >
 
         <a-assets>
@@ -100,11 +100,11 @@
                     <a-entity class="right-controller"></a-entity>
 
                     <a-entity class="model" gltf-model-plus="inflate: true">
-                        <template data-selector=".RootScene">
-                            <a-entity ik-controller hand-pose__left hand-pose__right animation-mixer space-invader-mesh="meshSelector: .Bot_Skinned"></a-entity>
+                        <template data-name="RootScene">
+                            <a-entity ik-controller hand-pose__left hand-pose__right animation-mixer space-invader-mesh="meshName: Bot_Skinned"></a-entity>
                         </template>
 
-                        <template data-selector=".Neck">
+                        <template data-name="Neck">
                             <a-entity>
                                 <a-entity
                                    class="nametag"
@@ -116,62 +116,38 @@
                             </a-entity>
                         </template>
 
-                        <template data-selector=".Chest">
-                            <a-entity>
-                              <a-entity personal-space-invader="radius: 0.2; useMaterial: true;" bone-visibility> </a-entity>
-                              <a-entity billboard>
-                                  <a-entity
-                                      block-button
-                                      visible-while-frozen
-                                      ui-class-while-frozen
-                                      text-button="haptic:#player-right-controller;
-                                                   textHoverColor: #fff;
-                                                   textColor: #fff;
-                                                   backgroundHoverColor: #ea4b54;
-                                                   backgroundColor: #fff;"
-                                      slice9="width: 0.45;
-                                                   height: 0.2;
-                                                   left: 53;
-                                                   top: 53;
-                                                   right: 10;
-                                                   bottom: 10;
-                                                   opacity: 1.3;
-                                                   src: #tooltip"
-                                      position="0 0 .35">
-                                  </a-entity>
-                                  <a-entity
-                                      visible-while-frozen
-                                      text="value:Block;
-                                            width:2.5;
-                                            align:center;"
-                                      position="0 0 0.36"></a-entity>
-                              </a-entity>
+                        <template data-name="Chest">
+                          <a-entity personal-space-invader="radius: 0.2; useMaterial: true;" bone-visibility>
+                            <a-entity billboard>
+                              <a-entity mixin="rounded-text-button" block-button visible-while-frozen ui-class-while-frozen position="0 0 .35"> </a-entity>
+                              <a-entity visible-while-frozen text="value:Block; width:2.5; align:center;" position="0 0 0.36"></a-entity>
                             </a-entity>
+                          </a-entity>
                         </template>
 
-                        <template data-selector=".Head">
+                        <template data-name="Head">
                             <a-entity
                                 networked-audio-source
                                 networked-audio-analyser
                                 personal-space-invader="radius: 0.15; useMaterial: true;"
                                 bone-visibility
                             >
-                            <a-cylinder
-                                static-body
-                                radius="0.13"
-                                height="0.2"
-                                position="0 0.07 0.05"
-                                visible="false"
-                            ></a-cylinder>
+                              <a-cylinder
+                                  static-body
+                                  radius="0.13"
+                                  height="0.2"
+                                  position="0 0.07 0.05"
+                                  visible="false"
+                              ></a-cylinder>
                             </a-entity>
                         </template>
 
-                        <template data-selector=".LeftHand">
-                            <a-entity personal-space-invader="radius: 0.1" bone-visibility></a-entity>
+                        <template data-name="LeftHand">
+                          <a-entity personal-space-invader="radius: 0.1" bone-visibility></a-entity>
                         </template>
 
-                        <template data-selector=".RightHand">
-                            <a-entity personal-space-invader="radius: 0.1" bone-visibility></a-entity>
+                        <template data-name="RightHand">
+                          <a-entity personal-space-invader="radius: 0.1" bone-visibility></a-entity>
                         </template>
                     </a-entity>
                 </a-entity>
@@ -183,27 +159,90 @@
                     class="interactable"
                     super-networked-interactable="counter: #counter; mass: 1;"
                     body="type: dynamic; shape: none; mass: 1;"
+                    auto-scale-cannon-physics-body
                     grabbable
-                    stretchable="useWorldPosition: true;"
+                    stretchable="useWorldPosition: true; usePhysics: never"
                     hoverable
                     duck
+                    sticky-object="autoLockOnRelease: true;"
                 ></a-entity>
             </template>
 
+            <template id="interactable-model">
+                <a-entity
+                    gltf-model-plus="inflate: false;"
+                    class="interactable"
+                    super-networked-interactable="counter: #media-counter; mass: 1;"
+                    body="type: dynamic; shape: none; mass: 1;"
+                    grabbable
+                    stretchable="useWorldPosition: true; usePhysics: never"
+                    hoverable
+                    sticky-object="autoLockOnRelease: true; autoLockOnLoad: true;"
+                    auto-box-collider
+                    position-at-box-shape-border="target:.delete-button"
+                    auto-scale-cannon-physics-body
+                >
+                    <a-entity class="delete-button" visible-while-frozen scale="0.08 0.08 0.08">
+                        <a-entity mixin="rounded-text-button" remove-object-button position="0 0 0"> </a-entity>
+                        <a-entity text=" value:Delete; width:2.5; align:center;" text-raycast-hack position="0 0 0.01"></a-entity>
+                    </a-entity>
+                </a-entity>
+            </template>
+
+            <template id="interactable-image">
+                <a-entity
+                    class="interactable"
+                    super-networked-interactable="counter: #media-counter; mass: 1;"
+                    body="type: dynamic; shape: none; mass: 1;"
+                    auto-scale-cannon-physics-body
+                    grabbable
+                    stretchable="useWorldPosition: true; usePhysics: never"
+                    hoverable
+                    geometry="primitive: plane"
+                    image-plus
+                    sticky-object="autoLockOnLoad: true; autoLockOnRelease: true;"
+                    position-at-box-shape-border="target:.delete-button;dirs:forward,back"
+                >
+                    <a-entity class="delete-button" visible-while-frozen>
+                        <a-entity mixin="rounded-text-button" remove-networked-object-button position="0 0 0"> </a-entity>
+                        <a-entity text=" value:Delete; width:2.5; align:center;" text-raycast-hack position="0 0 0.01"></a-entity>
+                    </a-entity>
+                </a-entity>
+            </template>
+
+            <a-mixin id="rounded-text-button"
+                text-button="
+                    haptic:#player-right-controller;
+                    textHoverColor: #fff;
+                    textColor: #fff;
+                    backgroundHoverColor: #ea4b54;
+                    backgroundColor: #fff;"
+                slice9="
+                    width: 0.45;
+                    height: 0.2;
+                    left: 53;
+                    top: 53;
+                    right: 10;
+                    bottom: 10;
+                    opacity: 1.3;
+                    src: #tooltip"
+            ></a-mixin>
+
             <a-mixin id="controller-super-hands"
-                super-hands="
-                    colliderEvent: collisions; colliderEventProperty: els;
-                    colliderEndEvent: collisions; colliderEndEventProperty: clearedEls;
-                    grabStartButtons: hand_grab; grabEndButtons: hand_release;
-                    stretchStartButtons: hand_grab; stretchEndButtons: hand_release;
-                    dragDropStartButtons: hand_grab; dragDropEndButtons: hand_release;"
-                collision-filter="collisionForces: false"
-                physics-collider
+                     super-hands="
+                         colliderEvent: collisions; colliderEventProperty: els;
+                         colliderEndEvent: collisions; colliderEndEventProperty: clearedEls;
+                         grabStartButtons: hand_grab; grabEndButtons: hand_release;
+                         stretchStartButtons: hand_grab; stretchEndButtons: hand_release;
+                         dragDropStartButtons: hand_grab; dragDropEndButtons: hand_release;"
+                     collision-filter="collisionForces: false"
+                     physics-collider
             ></a-mixin>
         </a-assets>
 
         <!-- Interactables -->
         <a-entity id="counter" networked-counter="max: 3; ttl: 120"></a-entity>
+        <a-entity id="media-counter" networked-counter="max: 10;"></a-entity>
 
         <a-entity
             id="cursor-controller"
@@ -243,126 +282,123 @@
             networked-avatar
             cardboard-controls
         >
-            <a-entity
-                id="player-hud"
-                hud-controller="head: #player-camera;"
-                vr-mode-toggle-visibility
-                vr-mode-toggle-playing__hud-controller
-            >
-                <a-entity in-world-hud="haptic:#player-right-controller;raycaster:#player-right-controller;" rotation="30 0 0">
-                    <a-rounded height="0.13" width="0.48" color="#000000" position="-0.24 -0.065 0" radius="0.065" opacity="0.35" class="hud bg"></a-rounded>
-                    <a-image icon-button="tooltip: #hud-tooltip; tooltipText: Mute Mic; activeTooltipText: Unmute Mic; image: #mute-off; hoverImage: #mute-off-hover; activeImage: #mute-on; activeHoverImage: #mute-on-hover" scale="0.1 0.1 0.1" position="-0.17 0 0.001" class="ui hud mic" material="alphaTest:0.1;"></a-image>
-                    <a-image icon-button="tooltip: #hud-tooltip; tooltipText: Pause; activeTooltipText: Resume; image: #freeze-off; hoverImage: #freeze-off-hover; activeImage: #freeze-on; activeHoverImage: #freeze-on-hover" scale="0.2 0.2 0.2" position="0 0 0.005" class="ui hud freeze"></a-image>
-                    <a-image icon-button="tooltip: #hud-tooltip; tooltipText: Enable Bubble; activeTooltipText: Disable Bubble; image: #bubble-off; hoverImage: #bubble-off-hover; activeImage: #bubble-on; activeHoverImage: #bubble-on-hover" scale="0.1 0.1 0.1" position="0.17 0 0.001" class="ui hud bubble" material="alphaTest:0.1;"></a-image>
-                    <a-rounded visible="false" id="hud-tooltip" height="0.08" width="0.3" color="#000000" position="-0.15 -0.2 0" rotation="-20 0 0" radius="0.025" opacity="0.35" class="hud bg">
-                        <a-entity text="value: Mute Mic; align:center;" position="0.15 0.04 0.001" ></a-entity>
-                    </a-rounded>
-                </a-entity>
-            </a-entity>
-
-            <a-entity
-                id="player-camera"
-                class="camera"
-                camera
-                position="0 1.6 0"
-                personal-space-bubble="radius: 0.4"
-                pitch-yaw-rotator
-            >
-                <a-entity
-                    id="gaze-teleport"
-                    position = "0.15 0 0"
-                    teleport-controls="
-                    cameraRig: #player-rig;
-                    teleportOrigin: #player-camera;
-                    button: gaze-teleport_;
-                    collisionEntities: [nav-mesh];
-                    drawIncrementally: true;
-                    incrementalDrawMs: 600;
-                    hitOpacity: 0.3;
-                    missOpacity: 0.1;
-                    curveShootingSpeed: 12;"
-                ></a-entity>
-                <a-entity id="player-camera-reverse-z" rotation="0 180 0"></a-entity>
-            </a-entity>
-
-            <a-entity
-                id="player-left-controller"
-                class="left-controller"
-                hand-controls2="left"
-                tracked-controls
-                teleport-controls="
-                    cameraRig: #player-rig;
-                    teleportOrigin: #player-camera;
-                    button: cursor-teleport_;
-                    collisionEntities: [nav-mesh];
-                    drawIncrementally: true;
-                    incrementalDrawMs: 600;
-                    hitOpacity: 0.3;
-                    missOpacity: 0.1;
-                    curveShootingSpeed: 12;"
-                haptic-feedback
-                body="type: static; shape: none;"
-                mixin="controller-super-hands"
-                controls-shape-offset
-            >
-                <a-entity id="player-left-controller-reverse-z" rotation="0 180 0"></a-entity>
+          <a-entity
+              id="player-hud"
+              hud-controller="head: #player-camera;"
+              vr-mode-toggle-visibility
+              vr-mode-toggle-playing__hud-controller
+          >
+            <a-entity in-world-hud="haptic:#player-right-controller;raycaster:#player-right-controller;" rotation="30 0 0">
+              <a-rounded height="0.13" width="0.48" color="#000000" position="-0.24 -0.065 0" radius="0.065" opacity="0.35" class="hud bg"></a-rounded>
+              <a-image icon-button="tooltip: #hud-tooltip; tooltipText: Mute Mic; activeTooltipText: Unmute Mic; image: #mute-off; hoverImage: #mute-off-hover; activeImage: #mute-on; activeHoverImage: #mute-on-hover" scale="0.1 0.1 0.1" position="-0.17 0 0.001" class="ui hud mic" material="alphaTest:0.1;"></a-image>
+              <a-image icon-button="tooltip: #hud-tooltip; tooltipText: Pause; activeTooltipText: Resume; image: #freeze-off; hoverImage: #freeze-off-hover; activeImage: #freeze-on; activeHoverImage: #freeze-on-hover" scale="0.2 0.2 0.2" position="0 0 0.005" class="ui hud freeze"></a-image>
+              <a-image icon-button="tooltip: #hud-tooltip; tooltipText: Enable Bubble; activeTooltipText: Disable Bubble; image: #bubble-off; hoverImage: #bubble-off-hover; activeImage: #bubble-on; activeHoverImage: #bubble-on-hover" scale="0.1 0.1 0.1" position="0.17 0 0.001" class="ui hud bubble" material="alphaTest:0.1;"></a-image>
+              <a-rounded visible="false" id="hud-tooltip" height="0.08" width="0.3" color="#000000" position="-0.15 -0.2 0" rotation="-20 0 0" radius="0.025" opacity="0.35" class="hud bg">
+                <a-entity text="value: Mute Mic; align:center;" position="0.15 0.04 0.001" ></a-entity>
+              </a-rounded>
             </a-entity>
-
+          </a-entity>
+
+          <a-entity
+              id="player-camera"
+              class="camera"
+              camera
+              position="0 1.6 0"
+              personal-space-bubble="radius: 0.4"
+              pitch-yaw-rotator
+          >
             <a-entity
-                id="player-right-controller"
-                class="right-controller"
-                hand-controls2="right"
-                tracked-controls
+                id="gaze-teleport"
+                position = "0.15 0 0"
                 teleport-controls="
                     cameraRig: #player-rig;
                     teleportOrigin: #player-camera;
-                    button: cursor-teleport_;
+                    button: gaze-teleport_;
                     collisionEntities: [nav-mesh];
                     drawIncrementally: true;
                     incrementalDrawMs: 600;
                     hitOpacity: 0.3;
                     missOpacity: 0.1;
                     curveShootingSpeed: 12;"
-                haptic-feedback
-                body="type: static; shape: none;"
-                mixin="controller-super-hands"
-                controls-shape-offset
-            >
-                <a-entity id="player-right-controller-reverse-z" rotation="0 180 0"></a-entity>
-            </a-entity>
+            ></a-entity>
+          </a-entity>
+
+          <a-entity
+              id="player-left-controller"
+              class="left-controller"
+              hand-controls2="left"
+              tracked-controls
+              teleport-controls="
+                  cameraRig: #player-rig;
+                  teleportOrigin: #player-camera;
+                  button: cursor-teleport_;
+                  collisionEntities: [nav-mesh];
+                  drawIncrementally: true;
+                  incrementalDrawMs: 600;
+                  hitOpacity: 0.3;
+                  missOpacity: 0.1;
+                  curveShootingSpeed: 12;"
+              haptic-feedback
+              body="type: static; shape: none;"
+              mixin="controller-super-hands"
+              controls-shape-offset
+          >
+          </a-entity>
+
+          <a-entity
+              id="player-right-controller"
+              class="right-controller"
+              hand-controls2="right"
+              tracked-controls
+              teleport-controls="
+                  cameraRig: #player-rig;
+                  teleportOrigin: #player-camera;
+                  button: cursor-teleport_;
+                  collisionEntities: [nav-mesh];
+                  drawIncrementally: true;
+                  incrementalDrawMs: 600;
+                  hitOpacity: 0.3;
+                  missOpacity: 0.1;
+                  curveShootingSpeed: 12;"
+              haptic-feedback
+              body="type: static; shape: none;"
+              mixin="controller-super-hands"
+              controls-shape-offset
+          >
+          </a-entity>
+
+          <a-entity gltf-model-plus="inflate: true;"
+                    class="model">
+            <template data-name="RootScene">
+              <a-entity
+                  ik-controller
+                  animation-mixer
+                  hand-pose__left
+                  hand-pose__right
+                  hand-pose-controller__left="networkedAvatar:#player-rig;eventSrc:#player-left-controller"
+                  hand-pose-controller__right="networkedAvatar:#player-rig;eventSrc:#player-right-controller"
+              ></a-entity>
+            </template>
 
-            <a-entity gltf-model-plus="inflate: true;"
-                      class="model">
-                <template data-selector=".RootScene">
-                    <a-entity
-                        ik-controller
-                        animation-mixer
-                        hand-pose__left
-                        hand-pose__right
-                        hand-pose-controller__left="networkedAvatar:#player-rig;eventSrc:#player-left-controller"
-                        hand-pose-controller__right="networkedAvatar:#player-rig;eventSrc:#player-right-controller"
-                    ></a-entity>
-                </template>
-
-                <template data-selector=".Neck">
-                    <a-entity>
-                        <a-entity class="nametag" visible="false" text ></a-entity>
-                    </a-entity>
-                </template>
+            <template data-name="Neck">
+              <a-entity>
+                <a-entity class="nametag" visible="false" text ></a-entity>
+              </a-entity>
+            </template>
 
-                <template data-selector=".Head">
-                    <a-entity visible="false" bone-visibility></a-entity>
-                </template>
+            <template data-name="Head">
+              <a-entity visible="false" bone-visibility></a-entity>
+            </template>
 
-                <template data-selector=".LeftHand">
-                    <a-entity bone-visibility></a-entity>
-                </template>
+            <template data-name="LeftHand">
+              <a-entity bone-visibility></a-entity>
+            </template>
 
-                <template data-selector=".RightHand">
-                    <a-entity bone-visibility></a-entity>
-                </template>
+            <template data-name="RightHand">
+              <a-entity bone-visibility></a-entity>
+            </template>
 
-            </a-entity>
+          </a-entity>
         </a-entity>
 
         <!-- Environment -->
@@ -371,6 +407,7 @@
             nav-mesh-helper
             static-body="shape: none;"
         ></a-entity>
+
     </a-scene>
 
     <div id="ui-root"></div>
diff --git a/src/hub.js b/src/hub.js
index 06369fd1d124211ebeace0c553371ea622295af3..51419d8d636cf18e1c4e292dade6146e156d0ff3 100644
--- a/src/hub.js
+++ b/src/hub.js
@@ -1,5 +1,6 @@
+console.log(`Hubs version: ${process.env.BUILD_VERSION || "?"}`);
+
 import "./assets/stylesheets/hub.scss";
-import moment from "moment-timezone";
 import queryString from "query-string";
 
 import { patchWebGLRenderingContext } from "./utils/webgl";
@@ -63,10 +64,16 @@ import "./components/networked-avatar";
 import "./components/css-class";
 import "./components/scene-shadow";
 import "./components/avatar-replay";
+import "./components/image-plus";
+import "./components/auto-box-collider";
 import "./components/pinch-to-move";
 import "./components/look-on-mobile";
 import "./components/pitch-yaw-rotator";
 import "./components/input-configurator";
+import "./components/sticky-object";
+import "./components/auto-scale-cannon-physics-body";
+import "./components/position-at-box-shape-border";
+import "./components/remove-networked-object-button";
 
 import ReactDOM from "react-dom";
 import React from "react";
@@ -75,6 +82,7 @@ import HubChannel from "./utils/hub-channel";
 import LinkChannel from "./utils/link-channel";
 import { connectToReticulum } from "./utils/phoenix-utils";
 import { disableiOSZoom } from "./utils/disable-ios-zoom";
+import { addMedia } from "./utils/media-utils";
 
 import "./systems/personal-space-bubble";
 import "./systems/app-mode";
@@ -296,11 +304,38 @@ const onReady = async () => {
       NAF.connection.entities.completeSync(ev.detail.clientId);
     });
 
+    scene.addEventListener("add_media", e => {
+      addMedia(e.detail);
+    });
+
+    if (qsTruthy("mediaTools")) {
+      document.addEventListener("paste", e => {
+        if (e.target.nodeName === "INPUT") return;
+
+        const imgUrl = e.clipboardData.getData("text");
+        console.log("Pasted: ", imgUrl, e);
+        addMedia(imgUrl);
+      });
+
+      document.addEventListener("dragover", e => {
+        e.preventDefault();
+      });
+
+      document.addEventListener("drop", e => {
+        e.preventDefault();
+        const imgUrl = e.dataTransfer.getData("url");
+        if (imgUrl) {
+          console.log("Droped: ", imgUrl);
+          addMedia(imgUrl);
+        }
+      });
+    }
+
     if (!qsTruthy("offline")) {
       document.body.addEventListener("connected", () => {
         if (!isBotMode) {
           hubChannel.sendEntryEvent().then(() => {
-            store.update({ activity: { lastEnteredAt: moment().toJSON() } });
+            store.update({ activity: { lastEnteredAt: new Date().toISOString() } });
           });
         }
         remountUI({ occupantCount: NAF.connection.adapter.publisher.initialOccupants.length + 1 });
@@ -398,6 +433,16 @@ const onReady = async () => {
     return;
   }
 
+  if (qs.required_version && process.env.BUILD_VERSION) {
+    const buildNumber = process.env.BUILD_VERSION.split(" ", 1)[0]; // e.g. "123 (abcd5678)"
+    if (qs.required_version !== buildNumber) {
+      remountUI({ roomUnavailableReason: "version_mismatch" });
+      setTimeout(() => document.location.reload(), 5000);
+      exitScene();
+      return;
+    }
+  }
+
   getAvailableVREntryTypes().then(availableVREntryTypes => {
     if (availableVREntryTypes.gearvr === VR_DEVICE_AVAILABILITY.yes) {
       remountUI({ availableVREntryTypes, forcedVREntryType: "gearvr" });
diff --git a/src/index.js b/src/index.js
index 2383019035144bc82277e15baa01b034f6244754..32558cd4ec93844c558ce5d45fed8d54824ac01a 100644
--- a/src/index.js
+++ b/src/index.js
@@ -7,7 +7,6 @@ import InfoDialog from "./react-components/info-dialog.js";
 import queryString from "query-string";
 
 const qs = queryString.parse(location.search);
-
 registerTelemetry();
 
 ReactDOM.render(
diff --git a/src/input-mappings.js b/src/input-mappings.js
index c6ce52501b479fa5749437db3da0882659129cb5..ccf44110bc74e38ab80de143c11f2dcad50d2a5c 100644
--- a/src/input-mappings.js
+++ b/src/input-mappings.js
@@ -149,8 +149,7 @@ const config = {
         m_press: "action_mute",
         q_press: "snap_rotate_left",
         e_press: "snap_rotate_right",
-        v_press: "action_share_screen",
-        b_press: "action_select_hud_item",
+        b_press: "action_share_screen",
 
         // We can't create a keyboard behaviour with AFIM yet,
         // so these will get captured by wasd-to-analog2d
diff --git a/src/network-schemas.js b/src/network-schemas.js
index ca4d0f401e3420c75ba6be1f69831ff2006f508c..a67b0d02381f0accc53472818dfdcac56951171a 100644
--- a/src/network-schemas.js
+++ b/src/network-schemas.js
@@ -95,6 +95,27 @@ function registerNetworkSchemas() {
       "scale"
     ]
   });
+
+  NAF.schemas.add({
+    template: "#interactable-image",
+    components: [
+      {
+        component: "position",
+        requiresNetworkUpdate: vectorRequiresUpdate(0.001)
+      },
+      {
+        component: "rotation",
+        requiresNetworkUpdate: vectorRequiresUpdate(0.5)
+      },
+      "scale",
+      "image-plus"
+    ]
+  });
+
+  NAF.schemas.add({
+    template: "#interactable-model",
+    components: ["position", "rotation", "scale", "gltf-model-plus"]
+  });
 }
 
 export default registerNetworkSchemas;
diff --git a/src/react-components/2d-hud.js b/src/react-components/2d-hud.js
index 141606a83d121f0a280ac5ac6b6d997856f7fc1e..e32a02d3eca931e112eb976b3c1cdcafc55f6dff 100644
--- a/src/react-components/2d-hud.js
+++ b/src/react-components/2d-hud.js
@@ -1,10 +1,30 @@
 import React from "react";
 import PropTypes from "prop-types";
 import cx from "classnames";
+import queryString from "query-string";
 
 import styles from "../assets/stylesheets/2d-hud.scss";
 
-const TwoDHUD = ({ muted, frozen, spacebubble, onToggleMute, onToggleFreeze, onToggleSpaceBubble }) => (
+import FontAwesomeIcon from "@fortawesome/react-fontawesome";
+import faPlus from "@fortawesome/fontawesome-free-solid/faPlus";
+
+const qs = queryString.parse(location.search);
+function qsTruthy(param) {
+  const val = qs[param];
+  // if the param exists but is not set (e.g. "?foo&bar"), its value is null.
+  return val === null || /1|on|true/i.test(val);
+}
+const enableMediaTools = qsTruthy("mediaTools");
+
+const TwoDHUD = ({
+  muted,
+  frozen,
+  spacebubble,
+  onToggleMute,
+  onToggleFreeze,
+  onToggleSpaceBubble,
+  onClickAddMedia
+}) => (
   <div className={styles.container}>
     <div className={cx("ui-interactive", styles.panel, styles.left)}>
       <div
@@ -25,6 +45,15 @@ const TwoDHUD = ({ muted, frozen, spacebubble, onToggleMute, onToggleFreeze, onT
         onClick={onToggleSpaceBubble}
       />
     </div>
+    {enableMediaTools ? (
+      <div
+        className={cx("ui-interactive", styles.iconButton, styles.small, styles.addMediaButton)}
+        title="Add Media"
+        onClick={onClickAddMedia}
+      >
+        <FontAwesomeIcon icon={faPlus} />
+      </div>
+    ) : null}
   </div>
 );
 
@@ -34,7 +63,8 @@ TwoDHUD.propTypes = {
   spacebubble: PropTypes.bool,
   onToggleMute: PropTypes.func,
   onToggleFreeze: PropTypes.func,
-  onToggleSpaceBubble: PropTypes.func
+  onToggleSpaceBubble: PropTypes.func,
+  onClickAddMedia: PropTypes.func
 };
 
 export default TwoDHUD;
diff --git a/src/react-components/entry-buttons.js b/src/react-components/entry-buttons.js
index f2551df70f99813c5e74be91e7e627de3374feab..d2bddfd908e9a40044d2349bd0729e38a7a25caf 100644
--- a/src/react-components/entry-buttons.js
+++ b/src/react-components/entry-buttons.js
@@ -1,7 +1,6 @@
 import React from "react";
 import { FormattedMessage } from "react-intl";
 import PropTypes from "prop-types";
-import MobileDetect from "mobile-detect";
 
 import MobileScreenEntryImg from "../assets/images/mobile_screen_entry.svg";
 import DesktopScreenEntryImg from "../assets/images/desktop_screen_entry.svg";
@@ -10,8 +9,6 @@ import GearVREntryImg from "../assets/images/gearvr_entry.svg";
 import DaydreamEntryImg from "../assets/images/daydream_entry.svg";
 import DeviceEntryImg from "../assets/images/device_entry.svg";
 
-const mobiledetect = new MobileDetect(navigator.userAgent);
-
 const EntryButton = props => (
   <button className="entry-button" onClick={props.onClick}>
     <img src={props.iconSrc} className="entry-button__icon" />
@@ -45,9 +42,9 @@ EntryButton.propTypes = {
 export const TwoDEntryButton = props => {
   const entryButtonProps = {
     ...props,
-    iconSrc: mobiledetect.mobile() ? MobileScreenEntryImg : DesktopScreenEntryImg,
+    iconSrc: AFRAME.utils.device.isMobile() ? MobileScreenEntryImg : DesktopScreenEntryImg,
     prefixMessageId: "entry.screen-prefix",
-    mediumMessageId: mobiledetect.mobile() ? "entry.mobile-screen" : "entry.desktop-screen"
+    mediumMessageId: AFRAME.utils.device.isMobile() ? "entry.mobile-screen" : "entry.desktop-screen"
   };
 
   return <EntryButton {...entryButtonProps} />;
@@ -59,7 +56,7 @@ export const GenericEntryButton = props => {
     iconSrc: GenericVREntryImg,
     prefixMessageId: "entry.generic-prefix",
     mediumMessageId: "entry.generic-medium",
-    subtitle: mobiledetect.mobile() ? null : "entry.generic-subtitle-desktop"
+    subtitle: AFRAME.utils.device.isMobile() ? null : "entry.generic-subtitle-desktop"
   };
 
   return <EntryButton {...entryButtonProps} />;
@@ -102,13 +99,13 @@ export const DeviceEntryButton = props => {
   const entryButtonProps = {
     ...props,
     iconSrc: DeviceEntryImg,
-    prefixMessageId: mobiledetect.mobile() ? "entry.device-prefix-mobile" : "entry.device-prefix-desktop",
+    prefixMessageId: AFRAME.utils.device.isMobile() ? "entry.device-prefix-mobile" : "entry.device-prefix-desktop",
     mediumMessageId: "entry.device-medium"
   };
 
   entryButtonProps.subtitle = entryButtonProps.isInHMD
     ? "entry.device-subtitle-vr"
-    : mobiledetect.mobile() ? "entry.device-subtitle-mobile" : "entry.device-subtitle-desktop";
+    : AFRAME.utils.device.isMobile() ? "entry.device-subtitle-mobile" : "entry.device-subtitle-desktop";
 
   return <EntryButton {...entryButtonProps} />;
 };
diff --git a/src/react-components/hub-create-panel.js b/src/react-components/hub-create-panel.js
index 7e44ce63093ad5ad3df772807794140cab9af2b9..dc10b99ac217609b07bda59422c805c5ea4f1581 100644
--- a/src/react-components/hub-create-panel.js
+++ b/src/react-components/hub-create-panel.js
@@ -85,7 +85,7 @@ class HubCreatePanel extends Component {
 
     const hub = await res.json();
 
-    if (process.env.NODE_ENV === "production") {
+    if (process.env.NODE_ENV === "production" || document.location.host === process.env.DEV_RETICULUM_SERVER) {
       document.location = hub.url;
     } else {
       document.location = `/hub.html?hub_id=${hub.hub_id}`;
diff --git a/src/react-components/info-dialog.js b/src/react-components/info-dialog.js
index a04127ff74ff2b4722d0a2fbe1a99dd07568efc0..83b46b9dfee5569044581b44879566a13ec3c934 100644
--- a/src/react-components/info-dialog.js
+++ b/src/react-components/info-dialog.js
@@ -8,6 +8,8 @@ import LinkDialog from "./link-dialog.js";
 
 // TODO i18n
 
+let lastAddMediaUrl = "";
+
 class InfoDialog extends Component {
   static dialogTypes = {
     slack: Symbol("slack"),
@@ -18,12 +20,14 @@ class InfoDialog extends Component {
     report: Symbol("report"),
     help: Symbol("help"),
     link: Symbol("link"),
-    webvr_recommend: Symbol("webvr_recommend")
+    webvr_recommend: Symbol("webvr_recommend"),
+    add_media: Symbol("add_media")
   };
   static propTypes = {
     dialogType: PropTypes.oneOf(Object.values(InfoDialog.dialogTypes)),
     onCloseDialog: PropTypes.func,
     onSubmittedEmail: PropTypes.func,
+    onAddMedia: PropTypes.func,
     linkCode: PropTypes.string
   };
 
@@ -34,14 +38,17 @@ class InfoDialog extends Component {
     this.shareLink = `${loc.protocol}//${loc.host}${loc.pathname}`;
     this.onKeyDown = this.onKeyDown.bind(this);
     this.onContainerClicked = this.onContainerClicked.bind(this);
+    this.onAddMediaClicked = this.onAddMediaClicked.bind(this);
   }
 
   componentDidMount() {
     window.addEventListener("keydown", this.onKeyDown);
+    this.setState({ addMediaUrl: lastAddMediaUrl });
   }
 
   componentWillUnmount() {
     window.removeEventListener("keydown", this.onKeyDown);
+    lastAddMediaUrl = this.state.addMediaUrl;
   }
 
   onKeyDown(e) {
@@ -56,6 +63,11 @@ class InfoDialog extends Component {
     }
   }
 
+  onAddMediaClicked() {
+    this.props.onAddMedia(this.state.addMediaUrl);
+    this.props.onCloseDialog();
+  }
+
   shareLinkClicked = () => {
     navigator.share({
       title: document.title,
@@ -71,7 +83,8 @@ class InfoDialog extends Component {
   state = {
     mailingListEmail: "",
     mailingListPrivacy: false,
-    copyLinkButtonText: "Copy"
+    copyLinkButtonText: "Copy",
+    addMediaUrl: ""
   };
 
   signUpForMailingList = async e => {
@@ -184,6 +197,31 @@ class InfoDialog extends Component {
           </div>
         );
         break;
+      case InfoDialog.dialogTypes.add_media:
+        dialogTitle = "Add Media";
+        dialogBody = (
+          <div>
+            <div>Tip: You can paste media urls directly into hubs with ctrl+v</div>
+            <form onSubmit={this.onAddMediaClicked}>
+              <div className="add-media-form">
+                <input
+                  type="url"
+                  placeholder="Image, Video, or GLTF URL"
+                  className="add-media-form__link_field"
+                  value={this.state.addMediaUrl}
+                  onChange={e => this.setState({ addMediaUrl: e.target.value })}
+                  required
+                />
+                <div className="add-media-form__buttons">
+                  <button className="add-media-form__action-button">
+                    <span>Add</span>
+                  </button>
+                </div>
+              </div>
+            </form>
+          </div>
+        );
+        break;
       case InfoDialog.dialogTypes.updates:
         dialogTitle = "";
         dialogBody = (
diff --git a/src/react-components/ui-root.js b/src/react-components/ui-root.js
index 9b6c10ea2149fd02899fe217a2ba89b2c33d2187..ce6358cb8ca3063f11fbb35894e7e69f99469840 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 MobileDetect from "mobile-detect";
 import { IntlProvider, FormattedMessage, addLocaleData } from "react-intl";
 import en from "react-intl/locale-data/en";
 import MovingAverage from "moving-average";
@@ -27,8 +26,6 @@ import Footer from "./footer";
 import FontAwesomeIcon from "@fortawesome/react-fontawesome";
 import faQuestion from "@fortawesome/fontawesome-free-solid/faQuestion";
 
-const mobiledetect = new MobileDetect(navigator.userAgent);
-
 addLocaleData([...en]);
 
 const ENTRY_STEPS = {
@@ -329,7 +326,7 @@ class UIRoot extends Component {
           mediaSource: "screen",
           // Work around BMO 1449832 by calculating the width. This will break for multi monitors if you share anything
           // other than your current monitor that has a different aspect ratio.
-          width: screen.width / screen.height * 720,
+          width: 720 * screen.width / screen.height,
           height: 720,
           frameRate: 30
         }
@@ -456,7 +453,7 @@ class UIRoot extends Component {
   };
 
   shouldShowHmdMicWarning = () => {
-    if (mobiledetect.mobile()) return false;
+    if (AFRAME.utils.device.isMobile()) return false;
     if (!this.state.enterInVR) return false;
     if (!this.hasHmdMicrophone()) return false;
 
@@ -484,7 +481,7 @@ class UIRoot extends Component {
   };
 
   onAudioReadyButton = () => {
-    if (mobiledetect.mobile() && !this.state.enterInVR && screenfull.enabled) {
+    if (AFRAME.utils.device.isMobile() && !this.state.enterInVR && screenfull.enabled) {
       screenfull.request();
     }
 
@@ -528,6 +525,10 @@ class UIRoot extends Component {
     this.setState({ infoDialogType: null, linkCode: null, linkCodeCancel: null });
   };
 
+  handleAddMedia = url => {
+    this.props.scene.emit("add_media", url);
+  };
+
   render() {
     if (this.state.exited || this.props.roomUnavailableReason || this.props.platformUnsupportedReason) {
       let subtitle = null;
@@ -609,7 +610,7 @@ class UIRoot extends Component {
     // Only show this in desktop firefox since other browsers/platforms will ignore the "screen" media constraint and
     // will attempt to share your webcam instead!
     const screenSharingCheckbox = this.props.enableScreenSharing &&
-      !mobiledetect.mobile() &&
+      !AFRAME.utils.device.isMobile() &&
       /firefox/i.test(navigator.userAgent) && (
         <label className="entry-panel__screen-sharing">
           <input
@@ -695,7 +696,7 @@ class UIRoot extends Component {
       clip: `rect(${maxLevelHeight - Math.floor(this.state.micLevel * maxLevelHeight)}px, 111px, 111px, 0px)`
     };
     const speakerClip = { clip: `rect(${this.state.tonePlaying ? 0 : maxLevelHeight}px, 111px, 111px, 0px)` };
-
+    const subtitleId = AFRAME.utils.device.isMobile() ? "audio.subtitle-mobile" : "audio.subtitle-desktop";
     const audioSetupPanel =
       this.state.entryStep === ENTRY_STEPS.audio_setup ? (
         <div className="audio-setup-panel">
@@ -704,9 +705,7 @@ class UIRoot extends Component {
               <FormattedMessage id="audio.title" />
             </div>
             <div className="audio-setup-panel__subtitle">
-              {(mobiledetect.mobile() || this.state.enterInVR) && (
-                <FormattedMessage id={mobiledetect.mobile() ? "audio.subtitle-mobile" : "audio.subtitle-desktop"} />
-              )}
+              {(AFRAME.utils.device.isMobile() || this.state.enterInVR) && <FormattedMessage id={subtitleId} />}
             </div>
             <div className="audio-setup-panel__levels">
               <div className="audio-setup-panel__levels__icon">
@@ -835,6 +834,7 @@ class UIRoot extends Component {
             linkCode={this.state.linkCode}
             onSubmittedEmail={() => this.setState({ infoDialogType: InfoDialog.dialogTypes.email_submitted })}
             onCloseDialog={this.handleCloseDialog}
+            onAddMedia={this.handleAddMedia}
           />
 
           {this.state.entryStep === ENTRY_STEPS.finished && (
@@ -872,6 +872,7 @@ class UIRoot extends Component {
                 onToggleMute={this.toggleMute}
                 onToggleFreeze={this.toggleFreeze}
                 onToggleSpaceBubble={this.toggleSpaceBubble}
+                onClickAddMedia={() => this.setState({ infoDialogType: InfoDialog.dialogTypes.add_media })}
               />
               <Footer
                 hubName={this.props.hubName}
diff --git a/src/storage/store.js b/src/storage/store.js
index e4e509ba3c1f1fc41b45d6816ba31f542f9c09cd..1b6d575ddd3f030a9b8e43897bfa907ba2842ffa 100644
--- a/src/storage/store.js
+++ b/src/storage/store.js
@@ -1,5 +1,5 @@
 import { Validator } from "jsonschema";
-import merge from "lodash/merge";
+import merge from "deepmerge";
 
 const LOCAL_STORE_KEY = "___hubs_store";
 const STORE_STATE_CACHE_KEY = Symbol();
diff --git a/src/systems/personal-space-bubble.js b/src/systems/personal-space-bubble.js
index 238c0b63f70c06502350b83dd354cbb256c755bf..23da15b839cd8130757587b2f717be154b243806 100644
--- a/src/systems/personal-space-bubble.js
+++ b/src/systems/personal-space-bubble.js
@@ -120,10 +120,10 @@ function createSphereGizmo(radius) {
  */
 AFRAME.registerComponent("space-invader-mesh", {
   schema: {
-    meshSelector: { type: "string" }
+    meshName: { type: "string" }
   },
-  init() {
-    this.targetMesh = this.el.querySelector(this.data.meshSelector).object3DMap.skinnedmesh;
+  update() {
+    this.targetMesh = this.el.object3D.getObjectByName(this.data.meshName);
   }
 });
 
diff --git a/src/utils/disable-ios-zoom.js b/src/utils/disable-ios-zoom.js
index c2d17104a6ae4b9b8c99221fe5bec8a8f79cc909..b80639e5b4c4c625330e3e31f19fe4cbf24c2ea3 100644
--- a/src/utils/disable-ios-zoom.js
+++ b/src/utils/disable-ios-zoom.js
@@ -1,8 +1,5 @@
-import MobileDetect from "mobile-detect";
-const mobiledetect = new MobileDetect(navigator.userAgent);
-
 export function disableiOSZoom() {
-  if (!mobiledetect.is("iPhone") && !mobiledetect.is("iPad")) return;
+  if (!AFRAME.utils.device.isIOS()) return;
 
   let lastTouchAtMs = 0;
 
diff --git a/src/utils/hub-channel.js b/src/utils/hub-channel.js
index aad95ec4e7550ff29181789b9e8a8c299f8961db..d8b87730a98dea1feb8f98793323d4b74a139553 100644
--- a/src/utils/hub-channel.js
+++ b/src/utils/hub-channel.js
@@ -1,4 +1,13 @@
-import moment from "moment-timezone";
+const MS_PER_DAY = 1000 * 60 * 60 * 24;
+const MS_PER_MONTH = 1000 * 60 * 60 * 24 * 30;
+
+function isSameMonth(da, db) {
+  return da.getFullYear() == db.getFullYear() && da.getMonth() == db.getMonth();
+}
+
+function isSameDay(da, db) {
+  return isSameMonth(da, db) && da.getDate() == db.getDate();
+}
 
 export default class HubChannel {
   constructor(store) {
@@ -52,18 +61,15 @@ export default class HubChannel {
       return entryTimingFlags;
     }
 
-    const lastEntered = moment(storedLastEnteredAt);
-    const lastEnteredPst = moment(lastEntered).tz("America/Los_Angeles");
-    const nowPst = moment().tz("America/Los_Angeles");
-    const dayWindowAgo = moment().subtract(1, "day");
-    const monthWindowAgo = moment().subtract(1, "month");
-
-    entryTimingFlags.isNewDaily =
-      lastEnteredPst.dayOfYear() !== nowPst.dayOfYear() || lastEnteredPst.year() !== nowPst.year();
-    entryTimingFlags.isNewMonthly =
-      lastEnteredPst.month() !== nowPst.month() || lastEnteredPst.year() !== nowPst.year();
-    entryTimingFlags.isNewDayWindow = lastEntered.isBefore(dayWindowAgo);
-    entryTimingFlags.isNewMonthWindow = lastEntered.isBefore(monthWindowAgo);
+    const now = new Date();
+    const lastEntered = new Date(storedLastEnteredAt);
+    const msSinceLastEntered = now - lastEntered;
+
+    // note that new daily and new monthly is based on client local time
+    entryTimingFlags.isNewDaily = !isSameDay(now, lastEntered);
+    entryTimingFlags.isNewMonthly = !isSameMonth(now, lastEntered);
+    entryTimingFlags.isNewDayWindow = msSinceLastEntered > MS_PER_DAY;
+    entryTimingFlags.isNewMonthWindow = msSinceLastEntered > MS_PER_MONTH;
 
     return entryTimingFlags;
   };
diff --git a/src/utils/media-utils.js b/src/utils/media-utils.js
new file mode 100644
index 0000000000000000000000000000000000000000..9c17813b926e5541eef96b586f8efb133e696f6c
--- /dev/null
+++ b/src/utils/media-utils.js
@@ -0,0 +1,75 @@
+const whitelistedHosts = [/^.*\.reticulum\.io$/, /^.*hubs\.mozilla\.com$/, /^hubs\.local$/];
+const isHostWhitelisted = hostname => !!whitelistedHosts.filter(r => r.test(hostname)).length;
+
+let resolveMediaUrl = "/api/v1/media";
+if (process.env.NODE_ENV === "development") {
+  resolveMediaUrl = `https://${process.env.DEV_RETICULUM_SERVER}${resolveMediaUrl}`;
+}
+
+export const resolveFarsparkUrl = async url => {
+  const parsedUrl = new URL(url);
+  if ((parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") || isHostWhitelisted(parsedUrl.hostname))
+    return url;
+
+  return (await fetch(resolveMediaUrl, {
+    method: "POST",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify({ media: { url } })
+  }).then(r => r.json())).raw;
+};
+
+const fetchContentType = async url => fetch(url, { method: "HEAD" }).then(r => r.headers.get("content-type"));
+let interactableId = 0;
+
+const offset = { x: 0, y: 0, z: -1.5 };
+export const spawnNetworkedImage = (src, contentType) => {
+  const scene = AFRAME.scenes[0];
+  const image = document.createElement("a-entity");
+  image.id = "interactable-image-" + interactableId++;
+  image.setAttribute("networked", { template: "#interactable-image" });
+  image.setAttribute("offset-relative-to", {
+    target: "#player-camera",
+    offset: offset,
+    selfDestruct: true
+  });
+  image.setAttribute("image-plus", { src, contentType });
+  scene.appendChild(image);
+  return image;
+};
+
+export const spawnNetworkedInteractable = src => {
+  const scene = AFRAME.scenes[0];
+  const model = document.createElement("a-entity");
+  model.id = "interactable-model-" + interactableId++;
+  model.setAttribute("networked", { template: "#interactable-model" });
+  model.setAttribute("offset-relative-to", {
+    on: "model-loaded",
+    target: "#player-camera",
+    offset: offset,
+    selfDestruct: true
+  });
+  model.setAttribute("gltf-model-plus", "src", src);
+  model.setAttribute("auto-box-collider", { resize: true });
+  scene.appendChild(model);
+  return model;
+};
+
+export const addMedia = async url => {
+  try {
+    const farsparkUrl = await resolveFarsparkUrl(url);
+    console.log("resolved", url, farsparkUrl);
+
+    const contentType = await fetchContentType(farsparkUrl);
+
+    if (contentType.startsWith("image/") || contentType.startsWith("video/")) {
+      spawnNetworkedImage(farsparkUrl, contentType);
+    } else if (contentType.startsWith("model/gltf") || url.endsWith(".gltf") || url.endsWith(".glb")) {
+      spawnNetworkedInteractable(farsparkUrl);
+    } else {
+      throw new Error(`Unsupported content type: ${contentType}`);
+    }
+  } catch (e) {
+    console.error("Error adding media", e);
+    spawnNetworkedImage("error");
+  }
+};
diff --git a/src/utils/vr-caps-detect.js b/src/utils/vr-caps-detect.js
index 8953f12a86f24e9848c08e3619d718a2b0eda615..685fb3ba326cebd1febc8f6d6005c7c846a6d99b 100644
--- a/src/utils/vr-caps-detect.js
+++ b/src/utils/vr-caps-detect.js
@@ -1,9 +1,6 @@
 const { detect } = require("detect-browser");
-import MobileDetect from "mobile-detect";
 
 const browser = detect();
-const deviceDetect = require("device-detect")();
-const mobiledetect = new MobileDetect(navigator.userAgent);
 
 // Precision on device detection is fuzzy -- we can sometimes know if a device is definitely
 // available, or definitely *not* available, and assume it may be available otherwise.
@@ -13,12 +10,12 @@ export const VR_DEVICE_AVAILABILITY = {
   maybe: "maybe" // Implies this device may support this VR platform, but may not be installed or in a compatible browser
 };
 
-function isMaybeGearVRCompatibleDevice() {
-  return navigator.userAgent.match(/\WAndroid\W/);
+function isMaybeGearVRCompatibleDevice(ua) {
+  return /\WAndroid\W/.test(ua);
 }
 
-function isMaybeDaydreamCompatibleDevice() {
-  return navigator.userAgent.match(/\WAndroid\W/);
+function isMaybeDaydreamCompatibleDevice(ua) {
+  return /\WAndroid\W/.test(ua);
 }
 
 // Blacklist of VR device name regex matchers that we do not want to consider as valid VR devices
@@ -46,8 +43,9 @@ const GENERIC_ENTRY_TYPE_DEVICE_BLACKLIST = [/cardboard/i];
 //
 // This function also detects if the user is already in a headset, and returns the isInHMD key to be `true` if so.
 export async function getAvailableVREntryTypes() {
-  const isSamsungBrowser = browser.name === "chrome" && navigator.userAgent.match(/SamsungBrowser/);
-  const isOculusBrowser = navigator.userAgent.match(/Oculus/);
+  const ua = navigator.userAgent;
+  const isSamsungBrowser = browser.name === "chrome" && /SamsungBrowser/.test(ua);
+  const isOculusBrowser = /Oculus/.test(ua);
   const isInHMD = isOculusBrowser;
 
   // This needs to be kept up-to-date with the latest browsers that can support VR and Hubs.
@@ -55,7 +53,7 @@ export async function getAvailableVREntryTypes() {
   const isWebVRCapableBrowser = window.hasNativeWebVRImplementation;
 
   const isDaydreamCapableBrowser = !!(isWebVRCapableBrowser && browser.name === "chrome" && !isSamsungBrowser);
-  const isIDevice = ["iPhone", "iPad", "iPod"].indexOf(deviceDetect.device) > -1;
+  const isIDevice = AFRAME.utils.device.isIOS();
   const isFirefoxBrowser = browser.name === "firefox";
   const isUIWebView = typeof navigator.mediaDevices === "undefined";
 
@@ -67,7 +65,7 @@ export async function getAvailableVREntryTypes() {
     ? VR_DEVICE_AVAILABILITY.no
     : isIDevice && isUIWebView ? VR_DEVICE_AVAILABILITY.maybe : VR_DEVICE_AVAILABILITY.yes;
 
-  let generic = mobiledetect.mobile() ? VR_DEVICE_AVAILABILITY.no : VR_DEVICE_AVAILABILITY.maybe;
+  let generic = AFRAME.utils.device.isMobile() ? VR_DEVICE_AVAILABILITY.no : VR_DEVICE_AVAILABILITY.maybe;
   let cardboard = VR_DEVICE_AVAILABILITY.no;
 
   // We only consider GearVR support as "maybe" and never "yes". The only browser
@@ -76,21 +74,21 @@ export async function getAvailableVREntryTypes() {
   //
   // If we are in Oculus Browser (ie, we are literally wearing a GearVR) then return 'yes'.
   let gearvr = VR_DEVICE_AVAILABILITY.no;
-  if (isMaybeGearVRCompatibleDevice()) {
+  if (isMaybeGearVRCompatibleDevice(ua)) {
     gearvr = isOculusBrowser ? VR_DEVICE_AVAILABILITY.yes : VR_DEVICE_AVAILABILITY.maybe;
   }
 
   // For daydream detection, we first check if they are on an Android compatible device, and assume they
   // may support daydream *unless* this browser has WebVR capabilities, in which case we can do better.
   let daydream =
-    isMaybeDaydreamCompatibleDevice() && !isInHMD ? VR_DEVICE_AVAILABILITY.maybe : VR_DEVICE_AVAILABILITY.no;
+    isMaybeDaydreamCompatibleDevice(ua) && !isInHMD ? VR_DEVICE_AVAILABILITY.maybe : VR_DEVICE_AVAILABILITY.no;
 
   if (isWebVRCapableBrowser) {
     const displays = await navigator.getVRDisplays();
 
     // Generic is supported for non-blacklisted devices and presentable HMDs.
     generic = displays.find(
-      d => d.capabilities.canPresent && !GENERIC_ENTRY_TYPE_DEVICE_BLACKLIST.find(r => d.displayName.match(r))
+      d => d.capabilities.canPresent && !GENERIC_ENTRY_TYPE_DEVICE_BLACKLIST.find(r => r.test(d.displayName))
     )
       ? VR_DEVICE_AVAILABILITY.yes
       : VR_DEVICE_AVAILABILITY.no;
@@ -98,12 +96,12 @@ export async function getAvailableVREntryTypes() {
     cardboard =
       !isIDevice &&
       !isFirefoxBrowser &&
-      displays.find(d => d.capabilities.canPresent && d.displayName.match(/cardboard/i))
+      displays.find(d => d.capabilities.canPresent && /cardboard/i.test(d.displayName))
         ? VR_DEVICE_AVAILABILITY.yes
         : VR_DEVICE_AVAILABILITY.no;
 
     // For daydream detection, in a WebVR browser we can increase confidence in daydream compatibility.
-    const hasDaydreamWebVRDevice = displays.find(d => d.displayName.match(/daydream/i));
+    const hasDaydreamWebVRDevice = displays.find(d => /daydream/i.test(d.displayName));
 
     if (hasDaydreamWebVRDevice) {
       // If we detected daydream via WebVR
diff --git a/src/vendor/GLTFLoader.js b/src/vendor/GLTFLoader.js
index 7610044b41b6dd9956b2d84984e46671de46e494..754ae5d9460e80e176a928cc53ea75ab7a6f5212 100644
--- a/src/vendor/GLTFLoader.js
+++ b/src/vendor/GLTFLoader.js
@@ -7,8 +7,11 @@
  * @author Tony Parisi / http://www.tonyparisi.com/
  * @author Takahiro / https://github.com/takahirox
  * @author Don McCurdy / https://www.donmccurdy.com
+ * @author netpro2k / https://github.com/netpro2k
  */
 
+ import { resolveFarsparkUrl } from "../utils/media-utils"
+
 THREE.GLTFLoader = ( function () {
 
 	function GLTFLoader( manager ) {
@@ -25,7 +28,7 @@ THREE.GLTFLoader = ( function () {
 
 		crossOrigin: 'Anonymous',
 
-		load: function ( url, onLoad, onProgress, onError ) {
+		load: async function ( url, onLoad, onProgress, onError ) {
 
 			var scope = this;
 
@@ -37,7 +40,9 @@ THREE.GLTFLoader = ( function () {
 
 			loader.setResponseType( 'arraybuffer' );
 
-			loader.load( url, function ( data ) {
+			var farsparkURL = await resolveFarsparkUrl(url);
+
+			loader.load( farsparkURL, function ( data ) {
 
 				try {
 
@@ -1598,7 +1603,7 @@ THREE.GLTFLoader = ( function () {
 	 * @param {number} bufferIndex
 	 * @return {Promise<ArrayBuffer>}
 	 */
-	GLTFParser.prototype.loadBuffer = function ( bufferIndex ) {
+	GLTFParser.prototype.loadBuffer = async function ( bufferIndex ) {
 
 		var bufferDef = this.json.buffers[ bufferIndex ];
 		var loader = this.fileLoader;
@@ -1618,9 +1623,11 @@ THREE.GLTFLoader = ( function () {
 
 		var options = this.options;
 
+		var farsparkURL = await resolveFarsparkUrl(resolveURL(bufferDef.uri, options.path));
+
 		return new Promise( function ( resolve, reject ) {
 
-			loader.load( resolveURL( bufferDef.uri, options.path ), resolve, undefined, function () {
+			loader.load( farsparkURL, resolve, undefined, function () {
 
 				reject( new Error( 'THREE.GLTFLoader: Failed to load buffer "' + bufferDef.uri + '".' ) );
 
@@ -1784,7 +1791,7 @@ THREE.GLTFLoader = ( function () {
 	 * @param {number} textureIndex
 	 * @return {Promise<THREE.Texture>}
 	 */
-	GLTFParser.prototype.loadTexture = function ( textureIndex ) {
+	GLTFParser.prototype.loadTexture = async function ( textureIndex ) {
 
 		var parser = this;
 		var json = this.json;
@@ -1798,11 +1805,12 @@ THREE.GLTFLoader = ( function () {
 		var sourceURI = source.uri;
 		var isObjectURL = false;
 
-		if ( source.bufferView !== undefined ) {
+    var hasBufferView = source.bufferView !== undefined;
+		if ( hasBufferView ) {
 
 			// Load binary image data from bufferView, if provided.
 
-			sourceURI = parser.getDependency( 'bufferView', source.bufferView ).then( function ( bufferView ) {
+			sourceURI = await parser.getDependency( 'bufferView', source.bufferView ).then( function ( bufferView ) {
 
 				isObjectURL = true;
 				var blob = new Blob( [ bufferView ], { type: source.mimeType } );
@@ -1813,6 +1821,11 @@ THREE.GLTFLoader = ( function () {
 
 		}
 
+    var urlToLoad = resolveURL(sourceURI, options.path);
+    if (!hasBufferView){
+      urlToLoad = await resolveFarsparkUrl(urlToLoad);
+    }
+
 		return Promise.resolve( sourceURI ).then( function ( sourceURI ) {
 
 			// Load Texture resource.
@@ -1821,7 +1834,7 @@ THREE.GLTFLoader = ( function () {
 
 			return new Promise( function ( resolve, reject ) {
 
-				loader.load( resolveURL( sourceURI, options.path ), resolve, undefined, reject );
+				loader.load( urlToLoad, resolve, undefined, reject );
 
 			} );
 
diff --git a/src/workers/gifparsing.worker.js b/src/workers/gifparsing.worker.js
new file mode 100644
index 0000000000000000000000000000000000000000..643a95ab98e52c048ef551a6249e3ef937191389
--- /dev/null
+++ b/src/workers/gifparsing.worker.js
@@ -0,0 +1,72 @@
+/**
+ *
+ * Gif parser by @gtk2k
+ * https://github.com/gtk2k/gtk2k.github.io/tree/master/animation_gif
+ *
+ */
+
+const parseGIF = function(gif, successCB, errorCB) {
+  let pos = 0;
+  const delayTimes = [];
+  let graphicControl = null;
+  const frames = [];
+  const disposals = [];
+  let loopCnt = 0;
+  if (
+    gif[0] === 0x47 &&
+    gif[1] === 0x49 &&
+    gif[2] === 0x46 && // 'GIF'
+    gif[3] === 0x38 &&
+    gif[4] === 0x39 &&
+    gif[5] === 0x61
+  ) {
+    // '89a'
+    pos += 13 + +!!(gif[10] & 0x80) * Math.pow(2, (gif[10] & 0x07) + 1) * 3;
+    const gifHeader = gif.subarray(0, pos);
+    while (gif[pos] && gif[pos] !== 0x3b) {
+      const offset = pos,
+        blockId = gif[pos];
+      if (blockId === 0x21) {
+        const label = gif[++pos];
+        if ([0x01, 0xfe, 0xf9, 0xff].indexOf(label) !== -1) {
+          label === 0xf9 && delayTimes.push((gif[pos + 3] + (gif[pos + 4] << 8)) * 10);
+          label === 0xff && (loopCnt = gif[pos + 15] + (gif[pos + 16] << 8));
+          while (gif[++pos]) pos += gif[pos];
+          if (label === 0xf9) {
+            graphicControl = gif.subarray(offset, pos + 1);
+            disposals.push((graphicControl[3] >> 2) & 0x07);
+          }
+        } else {
+          errorCB && errorCB("parseGIF: unknown label");
+          break;
+        }
+      } else if (blockId === 0x2c) {
+        pos += 9;
+        pos += 1 + +!!(gif[pos] & 0x80) * (Math.pow(2, (gif[pos] & 0x07) + 1) * 3);
+        while (gif[++pos]) pos += gif[pos];
+        const imageData = gif.subarray(offset, pos + 1);
+        frames.push(URL.createObjectURL(new Blob([gifHeader, graphicControl, imageData])));
+      } else {
+        errorCB && errorCB("parseGIF: unknown blockId");
+        break;
+      }
+      pos++;
+    }
+  } else {
+    errorCB && errorCB("parseGIF: no GIF89a");
+  }
+  successCB && successCB(delayTimes, loopCnt, frames, disposals);
+};
+
+self.onmessage = e => {
+  parseGIF(
+    new Uint8Array(e.data),
+    (delays, loopcnt, frames, disposals) => {
+      self.postMessage([true, frames, delays, disposals]);
+    },
+    err => {
+      console.error("Error in gif parsing worker", err);
+      self.postMessage([false, err]);
+    }
+  );
+};
diff --git a/webpack.config.js b/webpack.config.js
index 7f4daa883ea1e19355c8306afb3c5d4a2e5cf39b..454da02049360c1fc11b4c7bcbe820b6537faf2e 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -8,6 +8,7 @@ const webpack = require("webpack");
 const HTMLWebpackPlugin = require("html-webpack-plugin");
 const ExtractTextPlugin = require("extract-text-webpack-plugin");
 const CopyWebpackPlugin = require("copy-webpack-plugin");
+const UglifyJsPlugin = require("uglifyjs-webpack-plugin");
 const _ = require("lodash");
 
 const SMOKE_PREFIX = "smoke-";
@@ -99,6 +100,7 @@ const config = {
     useLocalIp: true,
     public: "hubs.local:8080",
     port: 8080,
+    headers: { "Access-Control-Allow-Origin": "*" },
     before: function(app) {
       // networked-aframe makes HEAD requests to the server for time syncing. Respond with an empty body.
       app.head("*", function(req, res, next) {
@@ -129,6 +131,14 @@ const config = {
           interpolate: "require"
         }
       },
+      {
+        test: /\.worker\.js$/,
+        loader: "worker-loader",
+        options: {
+          name: "assets/js/[name]-[hash].js",
+          publicPath: "/"
+        }
+      },
       {
         test: /\.js$/,
         include: [path.resolve(__dirname, "src")],
@@ -186,6 +196,10 @@ const config = {
       }
     ]
   },
+  // necessary due to https://github.com/visionmedia/debug/issues/547
+  optimization: {
+    minimizer: [new UglifyJsPlugin({ uglifyOptions: { compress: { collapse_vars: false } } })]
+  },
   plugins: [
     // Each output page needs a HTMLWebpackPlugin entry
     new HTMLWebpackPlugin({
@@ -258,7 +272,8 @@ const config = {
         NODE_ENV: process.env.NODE_ENV,
         JANUS_SERVER: process.env.JANUS_SERVER,
         DEV_RETICULUM_SERVER: process.env.DEV_RETICULUM_SERVER,
-        ASSET_BUNDLE_SERVER: process.env.ASSET_BUNDLE_SERVER
+        ASSET_BUNDLE_SERVER: process.env.ASSET_BUNDLE_SERVER,
+        BUILD_VERSION: process.env.BUILD_VERSION
       })
     })
   ]
diff --git a/yarn.lock b/yarn.lock
index 22663266a5572652e4b21a4a89850964cca3ae6a..6783d21e029245a2a0582351a0f119232f27df17 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -182,9 +182,9 @@ aframe-physics-extras@^0.1.3:
   version "0.1.3"
   resolved "https://registry.yarnpkg.com/aframe-physics-extras/-/aframe-physics-extras-0.1.3.tgz#803e2164fb96c0a80f2d1a81458f3277f262b130"
 
-"aframe-physics-system@github:donmccurdy/aframe-physics-system":
+"aframe-physics-system@https://github.com/mozillareality/aframe-physics-system#hubs/master":
   version "3.1.2"
-  resolved "https://codeload.github.com/donmccurdy/aframe-physics-system/tar.gz/c142a301e3ce76f88bab817c89daa5b3d4d97815"
+  resolved "https://github.com/mozillareality/aframe-physics-system#50f5deb1134eb0d43c0435d287eef7037818d3cc"
   dependencies:
     browserify "^14.3.0"
     budo "^10.0.3"
@@ -2501,6 +2501,10 @@ deep-is@~0.1.3:
   version "0.1.3"
   resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34"
 
+deepmerge@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-2.1.1.tgz#e862b4e45ea0555072bf51e7fd0d9845170ae768"
+
 define-properties@^1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.2.tgz#83a73f2fea569898fb737193c8f873caf6d45c94"
@@ -2627,10 +2631,6 @@ detective@^5.0.2:
     defined "^1.0.0"
     minimist "^1.1.1"
 
-device-detect@^1.0.7:
-  version "1.0.7"
-  resolved "https://registry.yarnpkg.com/device-detect/-/device-detect-1.0.7.tgz#d4f1aa2fc3afbbc7d4b4dc182b9822dffc50a708"
-
 diff@^2.1.2:
   version "2.2.3"
   resolved "https://registry.yarnpkg.com/diff/-/diff-2.2.3.tgz#60eafd0d28ee906e4e8ff0a52c1229521033bf99"
@@ -4956,7 +4956,7 @@ loader-utils@^0.2.16:
     json5 "^0.5.0"
     object-assign "^4.0.1"
 
-loader-utils@^1.0.1, loader-utils@^1.0.2, loader-utils@^1.1.0:
+loader-utils@^1.0.0, loader-utils@^1.0.1, loader-utils@^1.0.2, loader-utils@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.1.0.tgz#c98aef488bcceda2ffb5e2de646d6a754429f5cd"
   dependencies:
@@ -5266,6 +5266,10 @@ mime@1.4.1:
   version "1.4.1"
   resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6"
 
+mime@^2.0.3:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/mime/-/mime-2.3.1.tgz#b1621c54d63b97c47d3cfe7f7215f7d64517c369"
+
 mime@^2.1.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/mime/-/mime-2.2.0.tgz#161e541965551d3b549fa1114391e3a3d55b923b"
@@ -5284,9 +5288,9 @@ min-document@^2.19.0:
   dependencies:
     dom-walk "^0.1.0"
 
-minijanus@0.6.1:
-  version "0.6.1"
-  resolved "https://registry.yarnpkg.com/minijanus/-/minijanus-0.6.1.tgz#4d697313d58c4bdf9b762b6252981eaf7134c05f"
+minijanus@0.6.2:
+  version "0.6.2"
+  resolved "https://registry.yarnpkg.com/minijanus/-/minijanus-0.6.2.tgz#c5746bfbbd0573b5c3c47742f16646c6af6f897c"
 
 minimalistic-assert@^1.0.0:
   version "1.0.0"
@@ -5362,10 +5366,6 @@ mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkd
   dependencies:
     minimist "0.0.8"
 
-mobile-detect@^1.4.1:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/mobile-detect/-/mobile-detect-1.4.1.tgz#f4b67c49bb84bf0437f72e3067deb1c60ad7b23c"
-
 module-deps@^4.0.8:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/module-deps/-/module-deps-4.1.1.tgz#23215833f1da13fd606ccb8087b44852dcb821fd"
@@ -5406,16 +5406,6 @@ module-deps@^6.0.0:
     through2 "^2.0.0"
     xtend "^4.0.0"
 
-moment-timezone@^0.5.14:
-  version "0.5.14"
-  resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.14.tgz#4eb38ff9538b80108ba467a458f3ed4268ccfcb1"
-  dependencies:
-    moment ">= 2.9.0"
-
-"moment@>= 2.9.0", moment@^2.22.0:
-  version "2.22.0"
-  resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.0.tgz#7921ade01017dd45186e7fee5f424f0b8663a730"
-
 move-concurrently@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"
@@ -5471,12 +5461,12 @@ mute-stream@0.0.7:
   version "0.0.7"
   resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab"
 
-naf-janus-adapter@^0.9.0:
-  version "0.9.5"
-  resolved "https://registry.yarnpkg.com/naf-janus-adapter/-/naf-janus-adapter-0.9.5.tgz#81fcbf068daf66820892544de4d8357614e0152d"
+naf-janus-adapter@^0.10.1:
+  version "0.10.1"
+  resolved "https://registry.yarnpkg.com/naf-janus-adapter/-/naf-janus-adapter-0.10.1.tgz#e3bd4847dba002d38446d4bea4a6908b26229928"
   dependencies:
     debug "^3.1.0"
-    minijanus "0.6.1"
+    minijanus "0.6.2"
 
 nan@^2.3.0, nan@^2.3.2:
   version "2.9.1"
@@ -7196,7 +7186,7 @@ sax@~1.2.1:
   version "1.2.4"
   resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
 
-schema-utils@^0.4.2, schema-utils@^0.4.3, schema-utils@^0.4.5:
+schema-utils@^0.4.0, schema-utils@^0.4.2, schema-utils@^0.4.3, schema-utils@^0.4.5:
   version "0.4.5"
   resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-0.4.5.tgz#21836f0608aac17b78f9e3e24daff14a5ca13a3e"
   dependencies:
@@ -8268,6 +8258,14 @@ url-join@^2.0.2:
   version "2.0.5"
   resolved "https://registry.yarnpkg.com/url-join/-/url-join-2.0.5.tgz#5af22f18c052a000a48d7b82c5e9c2e2feeda728"
 
+url-loader@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/url-loader/-/url-loader-1.0.1.tgz#61bc53f1f184d7343da2728a1289ef8722ea45ee"
+  dependencies:
+    loader-utils "^1.1.0"
+    mime "^2.0.3"
+    schema-utils "^0.4.3"
+
 url-parse-lax@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-1.0.0.tgz#7af8f303645e9bd79a272e7a14ac68bc0609da73"
@@ -8673,6 +8671,13 @@ worker-farm@^1.5.2:
     errno "^0.1.4"
     xtend "^4.0.1"
 
+worker-loader@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/worker-loader/-/worker-loader-2.0.0.tgz#45fda3ef76aca815771a89107399ee4119b430ac"
+  dependencies:
+    loader-utils "^1.0.0"
+    schema-utils "^0.4.0"
+
 wrap-ansi@^2.0.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85"