diff --git a/.circleci/config.yml b/.circleci/config.yml
new file mode 100644
index 0000000000000000000000000000000000000000..4c799231c65b4508158d3231d58184e66b33e75a
--- /dev/null
+++ b/.circleci/config.yml
@@ -0,0 +1,20 @@
+version: 2
+jobs:
+  build:
+    docker:
+      - image: circleci/node:10-browsers
+    working_directory: ~/repo
+    steps:
+      - checkout
+      - restore_cache:
+          keys:
+          - v1-dependencies-{{ checksum "package-lock.json" }}
+          - v1-dependencies-
+      - run: npm ci
+      - save_cache:
+          paths:
+            - node_modules
+          key: v1-dependencies-{{ checksum "package-lock.json" }}
+      - run: npm test
+      - store_artifacts:
+          path: dist
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 92e3786c6ae68cfd8306ab619906411d65602716..0000000000000000000000000000000000000000
--- a/.travis.yml
+++ /dev/null
@@ -1,10 +0,0 @@
-language: node_js
-node_js: "10"
-cache:
-  directories:
-    - "$HOME/.npm"
-install:
-  - npm ci
-script:
-  - npm run lint
-  - npm run build
diff --git a/Jenkinsfile b/Jenkinsfile
index 6bc1e0d9b7d4b644e5e4d2b643d2a644408983de..ca13a7681168060b9a2b5bdc199177cb518dbd70 100644
--- a/Jenkinsfile
+++ b/Jenkinsfile
@@ -49,7 +49,7 @@ pipeline {
             "<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}`"
+            "`/mr hubs deploy ${env.BUILD_NUMBER} ${targetS3Url}`"
           )
           def payload = 'payload=' + JsonOutput.toJson([
             text      : text,
diff --git a/README.md b/README.md
index 503057f930fe9fc7cfe37c01a18990860274f5a7..4e96bc332b06bf6b6ae5f502dd36fb9b893f00c7 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,7 @@ This will allow the CSP checks to pass that are served up by Reticulum so you ca
 - `disable_telemetry` - If `true` disables Sentry telemetry.
 - `log_filter` - A `debug` style filter for setting the logging level.
 - `debug` - If `true` performs verbose logging of Janus and NAF traffic.
+- `disableTunnel` - Tunnel vision is on by default. Disable the tunnel vision by this parameter.
 
 ## Additional Resources
 
@@ -72,3 +73,4 @@ This will allow the CSP checks to pass that are served up by Reticulum so you ca
 * [Hubs-Ops](https://github.com/mozilla/hubs-ops) - Infrastructure as code + management tools for running necessary backend services on AWS.
 
 [![Waffle.io - Columns and their card count](https://badge.waffle.io/mozilla/socialmr.svg?columns=all)](http://waffle.io/mozilla/socialmr)
+ 
diff --git a/doc/image_orientations.gif b/doc/image_orientations.gif
new file mode 100755
index 0000000000000000000000000000000000000000..89c4c3ea1444de6b32e1cecc2d2825e430c833e8
Binary files /dev/null and b/doc/image_orientations.gif differ
diff --git a/package-lock.json b/package-lock.json
index 035aba4c5b879a87ec30fcb837f219d94bc5e61f..89f73fe4c6e6fbecf2d06e51f2c27b72284ae991 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -513,8 +513,8 @@
       }
     },
     "aframe": {
-      "version": "github:aframevr/aframe#1be48d9204f0919d6362ef4c4dfa955e4ef64439",
-      "from": "aframe@github:aframevr/aframe#1be48d9204f0919d6362ef4c4dfa955e4ef64439",
+      "version": "github:mozillareality/aframe#b9d11e68fdeaa75ae0c6893103b54bc149a2e38f",
+      "from": "github:mozillareality/aframe#bugfix/oculus-go-controller-reconnect-pre-e0c8ff7",
       "requires": {
         "@tweenjs/tween.js": "^16.8.0",
         "browserify-css": "^0.8.2",
@@ -527,19 +527,19 @@
         "present": "0.0.6",
         "promise-polyfill": "^3.1.0",
         "style-attr": "^1.0.2",
-        "three": "0.93.0",
+        "three": "0.94.0",
         "three-bmfont-text": "^2.1.0",
         "webvr-polyfill": "^0.10.5"
       },
       "dependencies": {
         "debug": {
           "version": "github:ngokevin/debug#ef5f8e66d49ce8bc64c6f282c15f8b7164409e3a",
-          "from": "debug@github:ngokevin/debug#ef5f8e66d49ce8bc64c6f282c15f8b7164409e3a"
+          "from": "github:ngokevin/debug#noTimestamp"
         },
         "three": {
-          "version": "0.93.0",
-          "resolved": "https://registry.npmjs.org/three/-/three-0.93.0.tgz",
-          "integrity": "sha1-P9bDZ+9FVKu7bhataZNig+iVwSM="
+          "version": "0.94.0",
+          "resolved": "https://registry.npmjs.org/three/-/three-0.94.0.tgz",
+          "integrity": "sha1-TObbfyv795wtc0RKpuPPwIoy12I="
         }
       }
     },
@@ -552,14 +552,54 @@
       "version": "github:mozillareality/aframe-input-mapping-component#03932457c5318db243e811d2767fe0c5a8c7e9e0",
       "from": "github:mozillareality/aframe-input-mapping-component#hubs/master"
     },
+    "aframe-inspector": {
+      "version": "0.8.3",
+      "resolved": "https://registry.npmjs.org/aframe-inspector/-/aframe-inspector-0.8.3.tgz",
+      "integrity": "sha512-zTLfIjuG6CHhFMAQH4UaYeEeqDFLr5ZBL/2JaCvidhi+odMvJJuZqqH/uZk50bsQh2umtb929AimWvmE1edLjQ==",
+      "requires": {
+        "classnames": "^2.2.5",
+        "clipboard": "^1.5.12",
+        "invariant": "^2.2.2",
+        "lodash.debounce": "^4.0.6",
+        "prop-types": "^15.6.0",
+        "react": "^15.3.0",
+        "react-dom": "^15.3.0",
+        "react-file-reader-input": "^1.1.4",
+        "react-select": "^1.0.0-rc.1"
+      },
+      "dependencies": {
+        "react": {
+          "version": "15.6.2",
+          "resolved": "https://registry.npmjs.org/react/-/react-15.6.2.tgz",
+          "integrity": "sha1-26BDSrQ5z+gvEI8PURZjkIF5qnI=",
+          "requires": {
+            "create-react-class": "^15.6.0",
+            "fbjs": "^0.8.9",
+            "loose-envify": "^1.1.0",
+            "object-assign": "^4.1.0",
+            "prop-types": "^15.5.10"
+          }
+        },
+        "react-dom": {
+          "version": "15.6.2",
+          "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-15.6.2.tgz",
+          "integrity": "sha1-Qc+t9pO3V/rycIRDodH9WgK+9zA=",
+          "requires": {
+            "fbjs": "^0.8.9",
+            "loose-envify": "^1.1.0",
+            "object-assign": "^4.1.0",
+            "prop-types": "^15.5.10"
+          }
+        }
+      }
+    },
     "aframe-motion-capture-components": {
       "version": "github:mozillareality/aframe-motion-capture-components#1ca616fa67b627e447b23b35a09da175d8387668",
       "from": "aframe-motion-capture-components@github:mozillareality/aframe-motion-capture-components#1ca616fa67b627e447b23b35a09da175d8387668"
     },
     "aframe-physics-extras": {
-      "version": "0.1.3",
-      "resolved": "https://registry.yarnpkg.com/aframe-physics-extras/-/aframe-physics-extras-0.1.3.tgz",
-      "integrity": "sha1-gD4hZPuWwKgPLRqBRY8yd/JisTA="
+      "version": "github:mozillareality/aframe-physics-extras#3a00539a9c9f259df3a55c70648b1ec0648b5479",
+      "from": "github:mozillareality/aframe-physics-extras#bugfix/physics-collider-world"
     },
     "aframe-physics-system": {
       "version": "github:mozillareality/aframe-physics-system#ecc5c9c533d6d9c71f8d6453ab961ed074d44b1c",
@@ -584,6 +624,11 @@
       "version": "github:mozillareality/aframe-teleport-controls#14f296cad85cea6d15ee5ba08b142526ff9573f4",
       "from": "github:mozillareality/aframe-teleport-controls#hubs/master"
     },
+    "after": {
+      "version": "0.8.2",
+      "resolved": "https://registry.npmjs.org/after/-/after-0.8.2.tgz",
+      "integrity": "sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8="
+    },
     "ajv": {
       "version": "6.5.2",
       "resolved": "https://registry.yarnpkg.com/ajv/-/ajv-6.5.2.tgz",
@@ -748,6 +793,11 @@
       "resolved": "https://registry.npmjs.org/array-shuffle/-/array-shuffle-1.0.1.tgz",
       "integrity": "sha1-fqSIKjVrS8pfVF4LblLq9tlxVXo="
     },
+    "array-slice": {
+      "version": "0.2.3",
+      "resolved": "https://registry.npmjs.org/array-slice/-/array-slice-0.2.3.tgz",
+      "integrity": "sha1-3Tz7gO15c6dRF82sabC5nshhhvU="
+    },
     "array-union": {
       "version": "1.0.2",
       "resolved": "https://registry.yarnpkg.com/array-union/-/array-union-1.0.2.tgz",
@@ -769,6 +819,11 @@
       "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=",
       "dev": true
     },
+    "arraybuffer.slice": {
+      "version": "0.0.6",
+      "resolved": "https://registry.npmjs.org/arraybuffer.slice/-/arraybuffer.slice-0.0.6.tgz",
+      "integrity": "sha1-8zshWfBTKj8xB6JywMz70a0peco="
+    },
     "arrify": {
       "version": "1.0.1",
       "resolved": "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz",
@@ -858,8 +913,7 @@
     "async-each": {
       "version": "1.0.1",
       "resolved": "https://registry.yarnpkg.com/async-each/-/async-each-1.0.1.tgz",
-      "integrity": "sha1-GdOGodntxufByF04iu28xW0zYC0=",
-      "dev": true
+      "integrity": "sha1-GdOGodntxufByF04iu28xW0zYC0="
     },
     "async-foreach": {
       "version": "0.1.3",
@@ -1956,6 +2010,11 @@
       "integrity": "sha1-ry87iPpvXB5MY00aD46sT1WzleM=",
       "dev": true
     },
+    "backo2": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz",
+      "integrity": "sha1-MasayLEpNjRj41s+u2n038+6eUc="
+    },
     "bail": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/bail/-/bail-1.0.3.tgz",
@@ -1998,12 +2057,22 @@
       "resolved": "https://registry.npmjs.org/base62/-/base62-1.2.8.tgz",
       "integrity": "sha512-V6YHUbjLxN1ymqNLb1DPHoU1CpfdL7d2YTIp5W3U4hhoG4hhxNmsFDs66M9EXxBiSEke5Bt5dwdfMwwZF70iLA=="
     },
+    "base64-arraybuffer": {
+      "version": "0.1.5",
+      "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz",
+      "integrity": "sha1-c5JncZI7Whl0etZmqlzUv5xunOg="
+    },
     "base64-js": {
       "version": "1.3.0",
       "resolved": "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.0.tgz",
       "integrity": "sha1-yrHmEY8FEJXli1KBrqjBzSK/wOM=",
       "dev": true
     },
+    "base64id": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/base64id/-/base64id-1.0.0.tgz",
+      "integrity": "sha1-R2iMuZu2gE8OBtPnY7HDLlfY5rY="
+    },
     "batch": {
       "version": "0.6.1",
       "resolved": "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz",
@@ -2020,6 +2089,14 @@
         "tweetnacl": "^0.14.3"
       }
     },
+    "better-assert": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/better-assert/-/better-assert-1.0.2.tgz",
+      "integrity": "sha1-QIZrnhueC1W0gYlDEeaPr/rrxSI=",
+      "requires": {
+        "callsite": "1.0.0"
+      }
+    },
     "big.js": {
       "version": "3.2.0",
       "resolved": "https://registry.yarnpkg.com/big.js/-/big.js-3.2.0.tgz",
@@ -2029,8 +2106,7 @@
     "binary-extensions": {
       "version": "1.11.0",
       "resolved": "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.11.0.tgz",
-      "integrity": "sha1-RqoXUftqL5PuXmibsQh9SxTGwgU=",
-      "dev": true
+      "integrity": "sha1-RqoXUftqL5PuXmibsQh9SxTGwgU="
     },
     "binaryextensions": {
       "version": "2.1.1",
@@ -2038,6 +2114,11 @@
       "integrity": "sha1-MgmlHKSkrVQaO409am1bg6JIWTU=",
       "dev": true
     },
+    "blob": {
+      "version": "0.0.4",
+      "resolved": "https://registry.npmjs.org/blob/-/blob-0.0.4.tgz",
+      "integrity": "sha1-vPEwUspURj8w+fx+lbmkdjCpSSE="
+    },
     "block-stream": {
       "version": "0.0.9",
       "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz",
@@ -2063,7 +2144,6 @@
       "version": "1.18.2",
       "resolved": "https://registry.yarnpkg.com/body-parser/-/body-parser-1.18.2.tgz",
       "integrity": "sha1-h2eKGdhLR9hZuDGZvVm84iKxBFQ=",
-      "dev": true,
       "requires": {
         "bytes": "3.0.0",
         "content-type": "~1.0.4",
@@ -2080,8 +2160,7 @@
         "iconv-lite": {
           "version": "0.4.19",
           "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz",
-          "integrity": "sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ==",
-          "dev": true
+          "integrity": "sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ=="
         }
       }
     },
@@ -2362,8 +2441,7 @@
     "bytes": {
       "version": "3.0.0",
       "resolved": "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz",
-      "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=",
-      "dev": true
+      "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg="
     },
     "cacache": {
       "version": "10.0.4",
@@ -2449,6 +2527,11 @@
         "callsites": "^0.2.0"
       }
     },
+    "callsite": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/callsite/-/callsite-1.0.0.tgz",
+      "integrity": "sha1-KAOY5dZkvXQDi28JBRU+borxvCA="
+    },
     "callsites": {
       "version": "0.2.0",
       "resolved": "https://registry.yarnpkg.com/callsites/-/callsites-0.2.0.tgz",
@@ -2763,6 +2846,16 @@
       "integrity": "sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk=",
       "dev": true
     },
+    "clipboard": {
+      "version": "1.7.1",
+      "resolved": "https://registry.npmjs.org/clipboard/-/clipboard-1.7.1.tgz",
+      "integrity": "sha1-Ng1taUbpmnof7zleQrqStem1oWs=",
+      "requires": {
+        "good-listener": "^1.2.2",
+        "select": "^1.1.2",
+        "tiny-emitter": "^2.0.0"
+      }
+    },
     "cliui": {
       "version": "4.1.0",
       "resolved": "https://registry.yarnpkg.com/cliui/-/cliui-4.1.0.tgz",
@@ -2978,11 +3071,20 @@
         }
       }
     },
+    "component-bind": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/component-bind/-/component-bind-1.0.0.tgz",
+      "integrity": "sha1-AMYIq33Nk4l8AAllGx06jh5zu9E="
+    },
     "component-emitter": {
       "version": "1.2.1",
       "resolved": "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz",
-      "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=",
-      "dev": true
+      "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY="
+    },
+    "component-inherit": {
+      "version": "0.0.3",
+      "resolved": "https://registry.npmjs.org/component-inherit/-/component-inherit-0.0.3.tgz",
+      "integrity": "sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM="
     },
     "compressible": {
       "version": "2.0.14",
@@ -3025,6 +3127,38 @@
         "typedarray": "^0.0.6"
       }
     },
+    "connect": {
+      "version": "3.6.6",
+      "resolved": "https://registry.npmjs.org/connect/-/connect-3.6.6.tgz",
+      "integrity": "sha1-Ce/2xVr3I24TcTWnJXSFi2eG9SQ=",
+      "requires": {
+        "debug": "2.6.9",
+        "finalhandler": "1.1.0",
+        "parseurl": "~1.3.2",
+        "utils-merge": "1.0.1"
+      },
+      "dependencies": {
+        "finalhandler": {
+          "version": "1.1.0",
+          "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.0.tgz",
+          "integrity": "sha1-zgtoVbRYU+eRsvzGgARtiCU91/U=",
+          "requires": {
+            "debug": "2.6.9",
+            "encodeurl": "~1.0.1",
+            "escape-html": "~1.0.3",
+            "on-finished": "~2.3.0",
+            "parseurl": "~1.3.2",
+            "statuses": "~1.3.1",
+            "unpipe": "~1.0.0"
+          }
+        },
+        "statuses": {
+          "version": "1.3.1",
+          "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.3.1.tgz",
+          "integrity": "sha1-+vUbnrdKrvOzrPStX2Gr8ky3uT4="
+        }
+      }
+    },
     "connect-history-api-fallback": {
       "version": "1.5.0",
       "resolved": "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-1.5.0.tgz",
@@ -3061,8 +3195,7 @@
     "content-type": {
       "version": "1.0.4",
       "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz",
-      "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==",
-      "dev": true
+      "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA=="
     },
     "convert-source-map": {
       "version": "1.5.1",
@@ -3073,8 +3206,7 @@
     "cookie": {
       "version": "0.3.1",
       "resolved": "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz",
-      "integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=",
-      "dev": true
+      "integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s="
     },
     "cookie-signature": {
       "version": "1.0.6",
@@ -3146,8 +3278,7 @@
     "core-js": {
       "version": "2.5.7",
       "resolved": "https://registry.yarnpkg.com/core-js/-/core-js-2.5.7.tgz",
-      "integrity": "sha1-+XJgj/DOrWi4QaFqky0LGDeRgU4=",
-      "dev": true
+      "integrity": "sha1-+XJgj/DOrWi4QaFqky0LGDeRgU4="
     },
     "core-util-is": {
       "version": "1.0.2",
@@ -3224,6 +3355,16 @@
         "sha.js": "^2.4.8"
       }
     },
+    "create-react-class": {
+      "version": "15.6.3",
+      "resolved": "https://registry.npmjs.org/create-react-class/-/create-react-class-15.6.3.tgz",
+      "integrity": "sha512-M+/3Q6E6DLO6Yx3OwrWjwHBnvfXXYA7W+dFjt/ZDBemHO1DDZhsalX/NUtnTYclN6GfnBDRh4qRHjcDHmlJBJg==",
+      "requires": {
+        "fbjs": "^0.8.9",
+        "loose-envify": "^1.3.1",
+        "object-assign": "^4.1.1"
+      }
+    },
     "cross-spawn": {
       "version": "6.0.5",
       "resolved": "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz",
@@ -3361,6 +3502,11 @@
         "array-find-index": "^1.0.1"
       }
     },
+    "custom-event": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz",
+      "integrity": "sha1-XQKkaFCt8bSjF5RqOSj8y1v9BCU="
+    },
     "cyclist": {
       "version": "0.2.2",
       "resolved": "https://registry.yarnpkg.com/cyclist/-/cyclist-0.2.2.tgz",
@@ -3404,7 +3550,6 @@
       "version": "2.6.9",
       "resolved": "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz",
       "integrity": "sha1-XRKFFd8TT/Mn6QpMk/Tgd6U2NB8=",
-      "dev": true,
       "requires": {
         "ms": "2.0.0"
       }
@@ -3622,6 +3767,11 @@
       "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=",
       "dev": true
     },
+    "delegate": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz",
+      "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw=="
+    },
     "delegates": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
@@ -3631,8 +3781,7 @@
     "depd": {
       "version": "1.1.2",
       "resolved": "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz",
-      "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=",
-      "dev": true
+      "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak="
     },
     "des.js": {
       "version": "1.0.0",
@@ -3693,6 +3842,11 @@
         "defined": "^1.0.0"
       }
     },
+    "di": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/di/-/di-0.0.1.tgz",
+      "integrity": "sha1-gGZJMmzqp8qjMG112YXqJ0i6kTw="
+    },
     "diff": {
       "version": "3.5.0",
       "resolved": "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz",
@@ -3756,7 +3910,7 @@
     },
     "document-register-element": {
       "version": "github:dmarcos/document-register-element#8ccc532b7f3744be954574caf3072a5fd260ca90",
-      "from": "document-register-element@github:dmarcos/document-register-element#8ccc532b7f3744be954574caf3072a5fd260ca90"
+      "from": "github:dmarcos/document-register-element#8ccc532b7"
     },
     "dom-converter": {
       "version": "0.1.4",
@@ -3767,6 +3921,17 @@
         "utila": "~0.3"
       }
     },
+    "dom-serialize": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/dom-serialize/-/dom-serialize-2.2.1.tgz",
+      "integrity": "sha1-ViromZ9Evl6jB29UGdzVnrQ6yVs=",
+      "requires": {
+        "custom-event": "~1.0.0",
+        "ent": "~2.2.0",
+        "extend": "^3.0.0",
+        "void-elements": "^2.0.0"
+      }
+    },
     "dom-serializer": {
       "version": "0.1.0",
       "resolved": "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.0.tgz",
@@ -3896,8 +4061,7 @@
     "ee-first": {
       "version": "1.1.1",
       "resolved": "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz",
-      "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=",
-      "dev": true
+      "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0="
     },
     "ejs": {
       "version": "2.6.1",
@@ -3941,8 +4105,7 @@
     "encodeurl": {
       "version": "1.0.2",
       "resolved": "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz",
-      "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=",
-      "dev": true
+      "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k="
     },
     "encoding": {
       "version": "0.1.12",
@@ -3961,6 +4124,90 @@
         "once": "^1.4.0"
       }
     },
+    "engine.io": {
+      "version": "1.8.5",
+      "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-1.8.5.tgz",
+      "integrity": "sha512-j1DWIcktw4hRwrv6nWx++5nFH2X64x16MAG2P0Lmi5Dvdfi3I+Jhc7JKJIdAmDJa+5aZ/imHV7dWRPy2Cqjh3A==",
+      "requires": {
+        "accepts": "1.3.3",
+        "base64id": "1.0.0",
+        "cookie": "0.3.1",
+        "debug": "2.3.3",
+        "engine.io-parser": "1.3.2",
+        "ws": "~1.1.5"
+      },
+      "dependencies": {
+        "accepts": {
+          "version": "1.3.3",
+          "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.3.tgz",
+          "integrity": "sha1-w8p0NJOGSMPg2cHjKN1otiLChMo=",
+          "requires": {
+            "mime-types": "~2.1.11",
+            "negotiator": "0.6.1"
+          }
+        },
+        "debug": {
+          "version": "2.3.3",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-2.3.3.tgz",
+          "integrity": "sha1-QMRT5n5uE8kB3ewxeviYbNqe/4w=",
+          "requires": {
+            "ms": "0.7.2"
+          }
+        },
+        "ms": {
+          "version": "0.7.2",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz",
+          "integrity": "sha1-riXPJRKziFodldfwN4aNhDESR2U="
+        }
+      }
+    },
+    "engine.io-client": {
+      "version": "1.8.5",
+      "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-1.8.5.tgz",
+      "integrity": "sha512-AYTgHyeVUPitsseqjoedjhYJapNVoSPShbZ+tEUX9/73jgZ/Z3sUlJf9oYgdEBBdVhupUpUqSxH0kBCXlQnmZg==",
+      "requires": {
+        "component-emitter": "1.2.1",
+        "component-inherit": "0.0.3",
+        "debug": "2.3.3",
+        "engine.io-parser": "1.3.2",
+        "has-cors": "1.1.0",
+        "indexof": "0.0.1",
+        "parsejson": "0.0.3",
+        "parseqs": "0.0.5",
+        "parseuri": "0.0.5",
+        "ws": "~1.1.5",
+        "xmlhttprequest-ssl": "1.5.3",
+        "yeast": "0.1.2"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "2.3.3",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-2.3.3.tgz",
+          "integrity": "sha1-QMRT5n5uE8kB3ewxeviYbNqe/4w=",
+          "requires": {
+            "ms": "0.7.2"
+          }
+        },
+        "ms": {
+          "version": "0.7.2",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz",
+          "integrity": "sha1-riXPJRKziFodldfwN4aNhDESR2U="
+        }
+      }
+    },
+    "engine.io-parser": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-1.3.2.tgz",
+      "integrity": "sha1-k3sHnwAH0Ik+xW1GyyILjLQ1Igo=",
+      "requires": {
+        "after": "0.8.2",
+        "arraybuffer.slice": "0.0.6",
+        "base64-arraybuffer": "0.1.5",
+        "blob": "0.0.4",
+        "has-binary": "0.1.7",
+        "wtf-8": "1.0.0"
+      }
+    },
     "enhanced-resolve": {
       "version": "4.1.0",
       "resolved": "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-4.1.0.tgz",
@@ -3972,6 +4219,11 @@
         "tapable": "^1.0.0"
       }
     },
+    "ent": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.0.tgz",
+      "integrity": "sha1-6WQhkyWiHQX0RGai9obtbOX13R0="
+    },
     "entities": {
       "version": "1.1.1",
       "resolved": "https://registry.yarnpkg.com/entities/-/entities-1.1.1.tgz",
@@ -4089,8 +4341,7 @@
     "escape-html": {
       "version": "1.0.3",
       "resolved": "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz",
-      "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=",
-      "dev": true
+      "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg="
     },
     "escape-string-regexp": {
       "version": "1.0.5",
@@ -4304,8 +4555,7 @@
     "eventemitter3": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.0.tgz",
-      "integrity": "sha512-ivIvhpq/Y0uSjcHDcOIccjmYjGLcP09MFGE7ysAwkAvkXfpZlC985pH2/ui64DKazbTW/4kN3yqozUxlXzI6cA==",
-      "dev": true
+      "integrity": "sha512-ivIvhpq/Y0uSjcHDcOIccjmYjGLcP09MFGE7ysAwkAvkXfpZlC985pH2/ui64DKazbTW/4kN3yqozUxlXzI6cA=="
     },
     "events": {
       "version": "1.1.1",
@@ -4381,6 +4631,50 @@
       "integrity": "sha1-8FyiM7SMBdVP/wd2XfhQfpXAL/g=",
       "dev": true
     },
+    "expand-braces": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/expand-braces/-/expand-braces-0.1.2.tgz",
+      "integrity": "sha1-SIsdHSRRyz06axks/AMPRMWFX+o=",
+      "requires": {
+        "array-slice": "^0.2.3",
+        "array-unique": "^0.2.1",
+        "braces": "^0.1.2"
+      },
+      "dependencies": {
+        "array-unique": {
+          "version": "0.2.1",
+          "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.2.1.tgz",
+          "integrity": "sha1-odl8yvy8JiXMcPrc6zalDFiwGlM="
+        },
+        "braces": {
+          "version": "0.1.5",
+          "resolved": "https://registry.npmjs.org/braces/-/braces-0.1.5.tgz",
+          "integrity": "sha1-wIVxEIUpHYt1/ddOqw+FlygHEeY=",
+          "requires": {
+            "expand-range": "^0.1.0"
+          }
+        },
+        "expand-range": {
+          "version": "0.1.1",
+          "resolved": "https://registry.npmjs.org/expand-range/-/expand-range-0.1.1.tgz",
+          "integrity": "sha1-TLjtoJk8pW+k9B/ELzy7TMrf8EQ=",
+          "requires": {
+            "is-number": "^0.1.1",
+            "repeat-string": "^0.2.2"
+          }
+        },
+        "is-number": {
+          "version": "0.1.1",
+          "resolved": "https://registry.npmjs.org/is-number/-/is-number-0.1.1.tgz",
+          "integrity": "sha1-aaevEWlj1HIG7JvZtIoUIW8eOAY="
+        },
+        "repeat-string": {
+          "version": "0.2.2",
+          "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-0.2.2.tgz",
+          "integrity": "sha1-x6jTI2BoNiBZp+RlH8aITosftK4="
+        }
+      }
+    },
     "expand-brackets": {
       "version": "2.1.4",
       "resolved": "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz",
@@ -4503,8 +4797,7 @@
     "extend": {
       "version": "3.0.2",
       "resolved": "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz",
-      "integrity": "sha1-+LETa0Bx+9jrFAr/hYsQGewpFfo=",
-      "dev": true
+      "integrity": "sha1-+LETa0Bx+9jrFAr/hYsQGewpFfo="
     },
     "extend-shallow": {
       "version": "2.0.1",
@@ -4963,7 +5256,6 @@
       "version": "1.5.7",
       "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.7.tgz",
       "integrity": "sha512-NONJVIFiX7Z8k2WxfqBjtwqMifx7X42ORLFrOZ2LTKGj71G3C0kfdyTqGqr8fx5zSX6Foo/D95dgGWbPUiwnew==",
-      "dev": true,
       "requires": {
         "debug": "^3.1.0"
       },
@@ -4972,7 +5264,6 @@
           "version": "3.1.0",
           "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
           "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
-          "dev": true,
           "requires": {
             "ms": "2.0.0"
           }
@@ -5080,14 +5371,12 @@
     "fs.realpath": {
       "version": "1.0.0",
       "resolved": "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz",
-      "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
-      "dev": true
+      "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
     },
     "fsevents": {
       "version": "1.2.4",
       "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.4.tgz",
       "integrity": "sha512-z8H8/diyk76B7q5wg+Ud0+CqzcAF3mBBI/bA5ne5zrRUUIvNkJY//D3BqyH571KuAC4Nr7Rw7CjWX4r0y9DvNg==",
-      "dev": true,
       "optional": true,
       "requires": {
         "nan": "^2.9.2",
@@ -5097,24 +5386,20 @@
         "abbrev": {
           "version": "1.1.1",
           "bundled": true,
-          "dev": true,
           "optional": true
         },
         "ansi-regex": {
           "version": "2.1.1",
-          "bundled": true,
-          "dev": true
+          "bundled": true
         },
         "aproba": {
           "version": "1.2.0",
           "bundled": true,
-          "dev": true,
           "optional": true
         },
         "are-we-there-yet": {
           "version": "1.1.4",
           "bundled": true,
-          "dev": true,
           "optional": true,
           "requires": {
             "delegates": "^1.0.0",
@@ -5123,13 +5408,11 @@
         },
         "balanced-match": {
           "version": "1.0.0",
-          "bundled": true,
-          "dev": true
+          "bundled": true
         },
         "brace-expansion": {
           "version": "1.1.11",
           "bundled": true,
-          "dev": true,
           "requires": {
             "balanced-match": "^1.0.0",
             "concat-map": "0.0.1"
@@ -5138,34 +5421,28 @@
         "chownr": {
           "version": "1.0.1",
           "bundled": true,
-          "dev": true,
           "optional": true
         },
         "code-point-at": {
           "version": "1.1.0",
-          "bundled": true,
-          "dev": true
+          "bundled": true
         },
         "concat-map": {
           "version": "0.0.1",
-          "bundled": true,
-          "dev": true
+          "bundled": true
         },
         "console-control-strings": {
           "version": "1.1.0",
-          "bundled": true,
-          "dev": true
+          "bundled": true
         },
         "core-util-is": {
           "version": "1.0.2",
           "bundled": true,
-          "dev": true,
           "optional": true
         },
         "debug": {
           "version": "2.6.9",
           "bundled": true,
-          "dev": true,
           "optional": true,
           "requires": {
             "ms": "2.0.0"
@@ -5174,25 +5451,21 @@
         "deep-extend": {
           "version": "0.5.1",
           "bundled": true,
-          "dev": true,
           "optional": true
         },
         "delegates": {
           "version": "1.0.0",
           "bundled": true,
-          "dev": true,
           "optional": true
         },
         "detect-libc": {
           "version": "1.0.3",
           "bundled": true,
-          "dev": true,
           "optional": true
         },
         "fs-minipass": {
           "version": "1.2.5",
           "bundled": true,
-          "dev": true,
           "optional": true,
           "requires": {
             "minipass": "^2.2.1"
@@ -5201,13 +5474,11 @@
         "fs.realpath": {
           "version": "1.0.0",
           "bundled": true,
-          "dev": true,
           "optional": true
         },
         "gauge": {
           "version": "2.7.4",
           "bundled": true,
-          "dev": true,
           "optional": true,
           "requires": {
             "aproba": "^1.0.3",
@@ -5223,7 +5494,6 @@
         "glob": {
           "version": "7.1.2",
           "bundled": true,
-          "dev": true,
           "optional": true,
           "requires": {
             "fs.realpath": "^1.0.0",
@@ -5237,13 +5507,11 @@
         "has-unicode": {
           "version": "2.0.1",
           "bundled": true,
-          "dev": true,
           "optional": true
         },
         "iconv-lite": {
           "version": "0.4.21",
           "bundled": true,
-          "dev": true,
           "optional": true,
           "requires": {
             "safer-buffer": "^2.1.0"
@@ -5252,7 +5520,6 @@
         "ignore-walk": {
           "version": "3.0.1",
           "bundled": true,
-          "dev": true,
           "optional": true,
           "requires": {
             "minimatch": "^3.0.4"
@@ -5261,7 +5528,6 @@
         "inflight": {
           "version": "1.0.6",
           "bundled": true,
-          "dev": true,
           "optional": true,
           "requires": {
             "once": "^1.3.0",
@@ -5270,19 +5536,16 @@
         },
         "inherits": {
           "version": "2.0.3",
-          "bundled": true,
-          "dev": true
+          "bundled": true
         },
         "ini": {
           "version": "1.3.5",
           "bundled": true,
-          "dev": true,
           "optional": true
         },
         "is-fullwidth-code-point": {
           "version": "1.0.0",
           "bundled": true,
-          "dev": true,
           "requires": {
             "number-is-nan": "^1.0.0"
           }
@@ -5290,26 +5553,22 @@
         "isarray": {
           "version": "1.0.0",
           "bundled": true,
-          "dev": true,
           "optional": true
         },
         "minimatch": {
           "version": "3.0.4",
           "bundled": true,
-          "dev": true,
           "requires": {
             "brace-expansion": "^1.1.7"
           }
         },
         "minimist": {
           "version": "0.0.8",
-          "bundled": true,
-          "dev": true
+          "bundled": true
         },
         "minipass": {
           "version": "2.2.4",
           "bundled": true,
-          "dev": true,
           "requires": {
             "safe-buffer": "^5.1.1",
             "yallist": "^3.0.0"
@@ -5318,7 +5577,6 @@
         "minizlib": {
           "version": "1.1.0",
           "bundled": true,
-          "dev": true,
           "optional": true,
           "requires": {
             "minipass": "^2.2.1"
@@ -5327,7 +5585,6 @@
         "mkdirp": {
           "version": "0.5.1",
           "bundled": true,
-          "dev": true,
           "requires": {
             "minimist": "0.0.8"
           }
@@ -5335,13 +5592,11 @@
         "ms": {
           "version": "2.0.0",
           "bundled": true,
-          "dev": true,
           "optional": true
         },
         "needle": {
           "version": "2.2.0",
           "bundled": true,
-          "dev": true,
           "optional": true,
           "requires": {
             "debug": "^2.1.2",
@@ -5352,7 +5607,6 @@
         "node-pre-gyp": {
           "version": "0.10.0",
           "bundled": true,
-          "dev": true,
           "optional": true,
           "requires": {
             "detect-libc": "^1.0.2",
@@ -5370,7 +5624,6 @@
         "nopt": {
           "version": "4.0.1",
           "bundled": true,
-          "dev": true,
           "optional": true,
           "requires": {
             "abbrev": "1",
@@ -5380,13 +5633,11 @@
         "npm-bundled": {
           "version": "1.0.3",
           "bundled": true,
-          "dev": true,
           "optional": true
         },
         "npm-packlist": {
           "version": "1.1.10",
           "bundled": true,
-          "dev": true,
           "optional": true,
           "requires": {
             "ignore-walk": "^3.0.1",
@@ -5396,7 +5647,6 @@
         "npmlog": {
           "version": "4.1.2",
           "bundled": true,
-          "dev": true,
           "optional": true,
           "requires": {
             "are-we-there-yet": "~1.1.2",
@@ -5407,19 +5657,16 @@
         },
         "number-is-nan": {
           "version": "1.0.1",
-          "bundled": true,
-          "dev": true
+          "bundled": true
         },
         "object-assign": {
           "version": "4.1.1",
           "bundled": true,
-          "dev": true,
           "optional": true
         },
         "once": {
           "version": "1.4.0",
           "bundled": true,
-          "dev": true,
           "requires": {
             "wrappy": "1"
           }
@@ -5427,19 +5674,16 @@
         "os-homedir": {
           "version": "1.0.2",
           "bundled": true,
-          "dev": true,
           "optional": true
         },
         "os-tmpdir": {
           "version": "1.0.2",
           "bundled": true,
-          "dev": true,
           "optional": true
         },
         "osenv": {
           "version": "0.1.5",
           "bundled": true,
-          "dev": true,
           "optional": true,
           "requires": {
             "os-homedir": "^1.0.0",
@@ -5449,19 +5693,16 @@
         "path-is-absolute": {
           "version": "1.0.1",
           "bundled": true,
-          "dev": true,
           "optional": true
         },
         "process-nextick-args": {
           "version": "2.0.0",
           "bundled": true,
-          "dev": true,
           "optional": true
         },
         "rc": {
           "version": "1.2.7",
           "bundled": true,
-          "dev": true,
           "optional": true,
           "requires": {
             "deep-extend": "^0.5.1",
@@ -5473,7 +5714,6 @@
             "minimist": {
               "version": "1.2.0",
               "bundled": true,
-              "dev": true,
               "optional": true
             }
           }
@@ -5481,7 +5721,6 @@
         "readable-stream": {
           "version": "2.3.6",
           "bundled": true,
-          "dev": true,
           "optional": true,
           "requires": {
             "core-util-is": "~1.0.0",
@@ -5496,7 +5735,6 @@
         "rimraf": {
           "version": "2.6.2",
           "bundled": true,
-          "dev": true,
           "optional": true,
           "requires": {
             "glob": "^7.0.5"
@@ -5504,43 +5742,36 @@
         },
         "safe-buffer": {
           "version": "5.1.1",
-          "bundled": true,
-          "dev": true
+          "bundled": true
         },
         "safer-buffer": {
           "version": "2.1.2",
           "bundled": true,
-          "dev": true,
           "optional": true
         },
         "sax": {
           "version": "1.2.4",
           "bundled": true,
-          "dev": true,
           "optional": true
         },
         "semver": {
           "version": "5.5.0",
           "bundled": true,
-          "dev": true,
           "optional": true
         },
         "set-blocking": {
           "version": "2.0.0",
           "bundled": true,
-          "dev": true,
           "optional": true
         },
         "signal-exit": {
           "version": "3.0.2",
           "bundled": true,
-          "dev": true,
           "optional": true
         },
         "string-width": {
           "version": "1.0.2",
           "bundled": true,
-          "dev": true,
           "requires": {
             "code-point-at": "^1.0.0",
             "is-fullwidth-code-point": "^1.0.0",
@@ -5550,7 +5781,6 @@
         "string_decoder": {
           "version": "1.1.1",
           "bundled": true,
-          "dev": true,
           "optional": true,
           "requires": {
             "safe-buffer": "~5.1.0"
@@ -5559,7 +5789,6 @@
         "strip-ansi": {
           "version": "3.0.1",
           "bundled": true,
-          "dev": true,
           "requires": {
             "ansi-regex": "^2.0.0"
           }
@@ -5567,13 +5796,11 @@
         "strip-json-comments": {
           "version": "2.0.1",
           "bundled": true,
-          "dev": true,
           "optional": true
         },
         "tar": {
           "version": "4.4.1",
           "bundled": true,
-          "dev": true,
           "optional": true,
           "requires": {
             "chownr": "^1.0.1",
@@ -5588,13 +5815,11 @@
         "util-deprecate": {
           "version": "1.0.2",
           "bundled": true,
-          "dev": true,
           "optional": true
         },
         "wide-align": {
           "version": "1.1.2",
           "bundled": true,
-          "dev": true,
           "optional": true,
           "requires": {
             "string-width": "^1.0.2"
@@ -5602,13 +5827,11 @@
         },
         "wrappy": {
           "version": "1.0.2",
-          "bundled": true,
-          "dev": true
+          "bundled": true
         },
         "yallist": {
           "version": "3.0.2",
-          "bundled": true,
-          "dev": true
+          "bundled": true
         }
       }
     },
@@ -5798,7 +6021,6 @@
       "version": "7.1.2",
       "resolved": "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz",
       "integrity": "sha1-wZyd+aAocC1nhhI4SmVSQExjbRU=",
-      "dev": true,
       "requires": {
         "fs.realpath": "^1.0.0",
         "inflight": "^1.0.4",
@@ -5981,6 +6203,14 @@
         }
       }
     },
+    "good-listener": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz",
+      "integrity": "sha1-1TswzfkxPf+33JoNR3CWqm0UXFA=",
+      "requires": {
+        "delegate": "^3.1.2"
+      }
+    },
     "got": {
       "version": "8.3.2",
       "resolved": "https://registry.yarnpkg.com/got/-/got-8.3.2.tgz",
@@ -6086,12 +6316,25 @@
         "ansi-regex": "^2.0.0"
       }
     },
+    "has-binary": {
+      "version": "0.1.7",
+      "resolved": "https://registry.npmjs.org/has-binary/-/has-binary-0.1.7.tgz",
+      "integrity": "sha1-aOYesWIQyVRaClzOBqhzkS/h5ow=",
+      "requires": {
+        "isarray": "0.0.1"
+      }
+    },
     "has-color": {
       "version": "0.1.7",
       "resolved": "https://registry.yarnpkg.com/has-color/-/has-color-0.1.7.tgz",
       "integrity": "sha1-ZxRKUmDDT8PMpnfQQdr1L+e3iy8=",
       "dev": true
     },
+    "has-cors": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-cors/-/has-cors-1.1.0.tgz",
+      "integrity": "sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk="
+    },
     "has-flag": {
       "version": "3.0.0",
       "resolved": "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz",
@@ -6398,7 +6641,6 @@
       "version": "1.6.3",
       "resolved": "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz",
       "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=",
-      "dev": true,
       "requires": {
         "depd": "~1.1.2",
         "inherits": "2.0.3",
@@ -6416,7 +6658,6 @@
       "version": "1.17.0",
       "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.17.0.tgz",
       "integrity": "sha512-Taqn+3nNvYRfJ3bGvKfBSRwy1v6eePlm3oc/aWVxZp57DQr5Eq3xhKJi7Z4hZpS8PC3H4qI+Yly5EmFacGuA/g==",
-      "dev": true,
       "requires": {
         "eventemitter3": "^3.0.0",
         "follow-redirects": "^1.0.0",
@@ -6566,8 +6807,7 @@
     "indexof": {
       "version": "0.0.1",
       "resolved": "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz",
-      "integrity": "sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=",
-      "dev": true
+      "integrity": "sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10="
     },
     "inflight": {
       "version": "1.0.6",
@@ -6796,7 +7036,6 @@
       "version": "1.0.1",
       "resolved": "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz",
       "integrity": "sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg=",
-      "dev": true,
       "requires": {
         "binary-extensions": "^1.0.0"
       }
@@ -7120,8 +7359,7 @@
     "isbinaryfile": {
       "version": "3.0.2",
       "resolved": "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-3.0.2.tgz",
-      "integrity": "sha1-Sj6XTsDLqQBNP8bN5yCeppNopiE=",
-      "dev": true
+      "integrity": "sha1-Sj6XTsDLqQBNP8bN5yCeppNopiE="
     },
     "isexe": {
       "version": "2.0.0",
@@ -7385,8 +7623,7 @@
     "json3": {
       "version": "3.3.2",
       "resolved": "https://registry.yarnpkg.com/json3/-/json3-3.3.2.tgz",
-      "integrity": "sha1-PAQ0dD35Pi9cQq7nsZvLSDV19OE=",
-      "dev": true
+      "integrity": "sha1-PAQ0dD35Pi9cQq7nsZvLSDV19OE="
     },
     "json5": {
       "version": "0.5.1",
@@ -7494,6 +7731,150 @@
         }
       }
     },
+    "karma": {
+      "version": "0.13.22",
+      "resolved": "https://registry.npmjs.org/karma/-/karma-0.13.22.tgz",
+      "integrity": "sha1-B3ULG9Bj1+fnuRvNLmNU2PKqh0Q=",
+      "requires": {
+        "batch": "^0.5.3",
+        "bluebird": "^2.9.27",
+        "body-parser": "^1.12.4",
+        "chokidar": "^1.4.1",
+        "colors": "^1.1.0",
+        "connect": "^3.3.5",
+        "core-js": "^2.1.0",
+        "di": "^0.0.1",
+        "dom-serialize": "^2.2.0",
+        "expand-braces": "^0.1.1",
+        "glob": "^7.0.0",
+        "graceful-fs": "^4.1.2",
+        "http-proxy": "^1.13.0",
+        "isbinaryfile": "^3.0.0",
+        "lodash": "^3.8.0",
+        "log4js": "^0.6.31",
+        "mime": "^1.3.4",
+        "minimatch": "^3.0.0",
+        "optimist": "^0.6.1",
+        "rimraf": "^2.3.3",
+        "socket.io": "^1.4.5",
+        "source-map": "^0.5.3",
+        "useragent": "^2.1.6"
+      },
+      "dependencies": {
+        "anymatch": {
+          "version": "1.3.2",
+          "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-1.3.2.tgz",
+          "integrity": "sha512-0XNayC8lTHQ2OI8aljNCN3sSx6hsr/1+rlcDAotXJR7C1oZZHCNsfpbKwMjRA3Uqb5tF1Rae2oloTr4xpq+WjA==",
+          "requires": {
+            "micromatch": "^2.1.5",
+            "normalize-path": "^2.0.0"
+          }
+        },
+        "arr-diff": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-2.0.0.tgz",
+          "integrity": "sha1-jzuCf5Vai9ZpaX5KQlasPOrjVs8=",
+          "requires": {
+            "arr-flatten": "^1.0.1"
+          }
+        },
+        "array-unique": {
+          "version": "0.2.1",
+          "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.2.1.tgz",
+          "integrity": "sha1-odl8yvy8JiXMcPrc6zalDFiwGlM="
+        },
+        "batch": {
+          "version": "0.5.3",
+          "resolved": "https://registry.npmjs.org/batch/-/batch-0.5.3.tgz",
+          "integrity": "sha1-PzQU84AyF0O/wQQvmoP/HVgk1GQ="
+        },
+        "bluebird": {
+          "version": "2.11.0",
+          "resolved": "http://registry.npmjs.org/bluebird/-/bluebird-2.11.0.tgz",
+          "integrity": "sha1-U0uQM8AiyVecVro7Plpcqvu2UOE="
+        },
+        "braces": {
+          "version": "1.8.5",
+          "resolved": "https://registry.npmjs.org/braces/-/braces-1.8.5.tgz",
+          "integrity": "sha1-uneWLhLf+WnWt2cR6RS3N4V79qc=",
+          "requires": {
+            "expand-range": "^1.8.1",
+            "preserve": "^0.2.0",
+            "repeat-element": "^1.1.2"
+          }
+        },
+        "chokidar": {
+          "version": "1.7.0",
+          "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-1.7.0.tgz",
+          "integrity": "sha1-eY5ol3gVHIB2tLNg5e3SjNortGg=",
+          "requires": {
+            "anymatch": "^1.3.0",
+            "async-each": "^1.0.0",
+            "fsevents": "^1.0.0",
+            "glob-parent": "^2.0.0",
+            "inherits": "^2.0.1",
+            "is-binary-path": "^1.0.0",
+            "is-glob": "^2.0.0",
+            "path-is-absolute": "^1.0.0",
+            "readdirp": "^2.0.0"
+          }
+        },
+        "expand-brackets": {
+          "version": "0.1.5",
+          "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-0.1.5.tgz",
+          "integrity": "sha1-3wcoTjQqgHzXM6xa9yQR5YHRF3s=",
+          "requires": {
+            "is-posix-bracket": "^0.1.0"
+          }
+        },
+        "extglob": {
+          "version": "0.3.2",
+          "resolved": "https://registry.npmjs.org/extglob/-/extglob-0.3.2.tgz",
+          "integrity": "sha1-Lhj/PS9JqydlzskCPwEdqo2DSaE=",
+          "requires": {
+            "is-extglob": "^1.0.0"
+          }
+        },
+        "glob-parent": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-2.0.0.tgz",
+          "integrity": "sha1-gTg9ctsFT8zPUzbaqQLxgvbtuyg=",
+          "requires": {
+            "is-glob": "^2.0.0"
+          }
+        },
+        "lodash": {
+          "version": "3.10.1",
+          "resolved": "http://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz",
+          "integrity": "sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y="
+        },
+        "micromatch": {
+          "version": "2.3.11",
+          "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-2.3.11.tgz",
+          "integrity": "sha1-hmd8l9FyCzY0MdBNDRUpO9OMFWU=",
+          "requires": {
+            "arr-diff": "^2.0.0",
+            "array-unique": "^0.2.1",
+            "braces": "^1.8.2",
+            "expand-brackets": "^0.1.4",
+            "extglob": "^0.3.1",
+            "filename-regex": "^2.0.0",
+            "is-extglob": "^1.0.0",
+            "is-glob": "^2.0.1",
+            "kind-of": "^3.0.2",
+            "normalize-path": "^2.0.1",
+            "object.omit": "^2.0.0",
+            "parse-glob": "^3.0.4",
+            "regex-cache": "^0.4.2"
+          }
+        },
+        "mime": {
+          "version": "1.6.0",
+          "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+          "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="
+        }
+      }
+    },
     "keyv": {
       "version": "3.0.0",
       "resolved": "https://registry.yarnpkg.com/keyv/-/keyv-3.0.0.tgz",
@@ -7733,15 +8114,15 @@
       }
     },
     "load-bmfont": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/load-bmfont/-/load-bmfont-1.3.0.tgz",
-      "integrity": "sha1-u358cQ3mvK/LE8s7jIHgwBMey8k=",
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/load-bmfont/-/load-bmfont-1.3.1.tgz",
+      "integrity": "sha512-lQkEawgez06lM2iw1vQEEOtVLJXyMzFcUqbwWMrB0g6zwhdUs/+e0KNd1zEJ7OFBbMVz0tbzQyjgjtTB47+PBg==",
       "requires": {
         "buffer-equal": "0.0.1",
         "mime": "^1.3.4",
         "parse-bmfont-ascii": "^1.0.3",
         "parse-bmfont-binary": "^1.0.5",
-        "parse-bmfont-xml": "^1.1.0",
+        "parse-bmfont-xml": "^1.1.4",
         "xhr": "^2.0.1",
         "xtend": "^4.0.0"
       },
@@ -7836,8 +8217,7 @@
     "lodash.debounce": {
       "version": "4.0.8",
       "resolved": "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
-      "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=",
-      "dev": true
+      "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168="
     },
     "lodash.mergewith": {
       "version": "4.6.1",
@@ -7878,6 +8258,33 @@
         }
       }
     },
+    "log4js": {
+      "version": "0.6.38",
+      "resolved": "http://registry.npmjs.org/log4js/-/log4js-0.6.38.tgz",
+      "integrity": "sha1-LElBFmldb7JUgJQ9P8hy5mKlIv0=",
+      "requires": {
+        "readable-stream": "~1.0.2",
+        "semver": "~4.3.3"
+      },
+      "dependencies": {
+        "readable-stream": {
+          "version": "1.0.34",
+          "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz",
+          "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=",
+          "requires": {
+            "core-util-is": "~1.0.0",
+            "inherits": "~2.0.1",
+            "isarray": "0.0.1",
+            "string_decoder": "~0.10.x"
+          }
+        },
+        "semver": {
+          "version": "4.3.6",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-4.3.6.tgz",
+          "integrity": "sha1-MAvG4OhjdPe6YQaLWx7NV/xlMto="
+        }
+      }
+    },
     "loglevel": {
       "version": "1.6.1",
       "resolved": "https://registry.yarnpkg.com/loglevel/-/loglevel-1.6.1.tgz",
@@ -7937,7 +8344,6 @@
       "version": "4.1.3",
       "resolved": "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.3.tgz",
       "integrity": "sha1-oRdc80lt/IQ2wVbDNLSVWZK85pw=",
-      "dev": true,
       "requires": {
         "pseudomap": "^1.0.2",
         "yallist": "^2.1.2"
@@ -7946,8 +8352,7 @@
         "yallist": {
           "version": "2.1.2",
           "resolved": "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz",
-          "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=",
-          "dev": true
+          "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI="
         }
       }
     },
@@ -8051,8 +8456,7 @@
     "media-typer": {
       "version": "0.3.0",
       "resolved": "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz",
-      "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=",
-      "dev": true
+      "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g="
     },
     "mem": {
       "version": "1.1.0",
@@ -8255,14 +8659,12 @@
     "mime-db": {
       "version": "1.35.0",
       "resolved": "https://registry.yarnpkg.com/mime-db/-/mime-db-1.35.0.tgz",
-      "integrity": "sha1-BWnWV0ZkkSg3CWY603mpm5DZq0c=",
-      "dev": true
+      "integrity": "sha1-BWnWV0ZkkSg3CWY603mpm5DZq0c="
     },
     "mime-types": {
       "version": "2.1.19",
       "resolved": "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.19.tgz",
       "integrity": "sha1-ceRkU3p++BwV8tudl+kT/A/2BvA=",
-      "dev": true,
       "requires": {
         "mime-db": "~1.35.0"
       }
@@ -8457,9 +8859,9 @@
       "dev": true
     },
     "naf-janus-adapter": {
-      "version": "0.11.0",
-      "resolved": "https://registry.npmjs.org/naf-janus-adapter/-/naf-janus-adapter-0.11.0.tgz",
-      "integrity": "sha512-jLwcs4TRj7Dur0bF9RjP9UyjIkdKpwNZ3q/zCXT+ytrHGRXWt4rX+9acdpg2QVEmPMumN+rr9NUPliNWpRxD3w==",
+      "version": "0.12.0",
+      "resolved": "https://registry.npmjs.org/naf-janus-adapter/-/naf-janus-adapter-0.12.0.tgz",
+      "integrity": "sha512-Zt2QC/7o2haXgaqerby+DCaDYth8CDtsbMBZettsQg4wufDKbK41xhej/KM9Wwgkfopu6YiK6CK4G1kcOgL/RA==",
       "requires": {
         "debug": "^3.1.0",
         "minijanus": "0.6.2",
@@ -8479,8 +8881,7 @@
     "nan": {
       "version": "2.10.0",
       "resolved": "https://registry.yarnpkg.com/nan/-/nan-2.10.0.tgz",
-      "integrity": "sha1-ltDNYQ69WNS03pzAxoKM2pnHVI8=",
-      "dev": true
+      "integrity": "sha1-ltDNYQ69WNS03pzAxoKM2pnHVI8="
     },
     "nanomatch": {
       "version": "1.2.13",
@@ -8547,8 +8948,7 @@
     "negotiator": {
       "version": "0.6.1",
       "resolved": "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz",
-      "integrity": "sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk=",
-      "dev": true
+      "integrity": "sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk="
     },
     "neo-async": {
       "version": "2.5.1",
@@ -8915,6 +9315,11 @@
       "resolved": "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz",
       "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
     },
+    "object-component": {
+      "version": "0.0.3",
+      "resolved": "https://registry.npmjs.org/object-component/-/object-component-0.0.3.tgz",
+      "integrity": "sha1-8MaapQ78lbhmwYb0AKM3acsvEpE="
+    },
     "object-copy": {
       "version": "0.1.0",
       "resolved": "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz",
@@ -8989,7 +9394,6 @@
       "version": "2.3.0",
       "resolved": "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz",
       "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=",
-      "dev": true,
       "requires": {
         "ee-first": "1.1.1"
       }
@@ -9023,6 +9427,27 @@
         "is-wsl": "^1.1.0"
       }
     },
+    "optimist": {
+      "version": "0.6.1",
+      "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz",
+      "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=",
+      "requires": {
+        "minimist": "~0.0.1",
+        "wordwrap": "~0.0.2"
+      },
+      "dependencies": {
+        "minimist": {
+          "version": "0.0.10",
+          "resolved": "http://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz",
+          "integrity": "sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8="
+        },
+        "wordwrap": {
+          "version": "0.0.3",
+          "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz",
+          "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc="
+        }
+      }
+    },
     "optionator": {
       "version": "0.8.2",
       "resolved": "https://registry.yarnpkg.com/optionator/-/optionator-0.8.2.tgz",
@@ -9037,6 +9462,11 @@
         "wordwrap": "~1.0.0"
       }
     },
+    "options": {
+      "version": "0.0.6",
+      "resolved": "https://registry.npmjs.org/options/-/options-0.0.6.tgz",
+      "integrity": "sha1-7CLTEoBrtT5zF3Pnza788cZDEo8="
+    },
     "ora": {
       "version": "0.2.3",
       "resolved": "https://registry.yarnpkg.com/ora/-/ora-0.2.3.tgz",
@@ -9110,8 +9540,7 @@
     "os-tmpdir": {
       "version": "1.0.2",
       "resolved": "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
-      "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=",
-      "dev": true
+      "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ="
     },
     "osenv": {
       "version": "0.1.5",
@@ -9250,9 +9679,9 @@
       "integrity": "sha1-0Di0dtPp3Z2x4RoLDlOiJ5K2kAY="
     },
     "parse-bmfont-xml": {
-      "version": "1.1.3",
-      "resolved": "https://registry.npmjs.org/parse-bmfont-xml/-/parse-bmfont-xml-1.1.3.tgz",
-      "integrity": "sha1-1rZqNxr9OcUAfZ8O6yYqTyzOe3w=",
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/parse-bmfont-xml/-/parse-bmfont-xml-1.1.4.tgz",
+      "integrity": "sha512-bjnliEOmGv3y1aMEfREMBJ9tfL3WR0i0CKPj61DnSLaoxWR3nLrsQrEbCId/8rF4NyRF0cCqisSVXyQYWM+mCQ==",
       "requires": {
         "xml-parse-from-string": "^1.0.0",
         "xml2js": "^0.4.5"
@@ -9306,17 +9735,40 @@
       "resolved": "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz",
       "integrity": "sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY="
     },
+    "parsejson": {
+      "version": "0.0.3",
+      "resolved": "https://registry.npmjs.org/parsejson/-/parsejson-0.0.3.tgz",
+      "integrity": "sha1-q343WfIJ7OmUN5c/fQ8fZK4OZKs=",
+      "requires": {
+        "better-assert": "~1.0.0"
+      }
+    },
+    "parseqs": {
+      "version": "0.0.5",
+      "resolved": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.5.tgz",
+      "integrity": "sha1-1SCKNzjkZ2bikbouoXNoSSGouJ0=",
+      "requires": {
+        "better-assert": "~1.0.0"
+      }
+    },
     "parserlib": {
       "version": "0.2.5",
       "resolved": "https://registry.yarnpkg.com/parserlib/-/parserlib-0.2.5.tgz",
       "integrity": "sha1-hZB92GBaoGq7PdKV1QuyuPpN0Rc=",
       "dev": true
     },
+    "parseuri": {
+      "version": "0.0.5",
+      "resolved": "https://registry.npmjs.org/parseuri/-/parseuri-0.0.5.tgz",
+      "integrity": "sha1-gCBKUNTbt3m/3G6+J3jZDkvOMgo=",
+      "requires": {
+        "better-assert": "~1.0.0"
+      }
+    },
     "parseurl": {
       "version": "1.3.2",
       "resolved": "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.2.tgz",
-      "integrity": "sha1-/CidTtiZMRlGDBViUyYs3I3mW/M=",
-      "dev": true
+      "integrity": "sha1-/CidTtiZMRlGDBViUyYs3I3mW/M="
     },
     "pascalcase": {
       "version": "0.1.1",
@@ -9822,8 +10274,7 @@
     "process-nextick-args": {
       "version": "2.0.0",
       "resolved": "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.0.tgz",
-      "integrity": "sha1-o31zL0JxtKsa0HDTVQjoKQeI/6o=",
-      "dev": true
+      "integrity": "sha1-o31zL0JxtKsa0HDTVQjoKQeI/6o="
     },
     "progress": {
       "version": "2.0.0",
@@ -9878,8 +10329,7 @@
     "pseudomap": {
       "version": "1.0.2",
       "resolved": "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz",
-      "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=",
-      "dev": true
+      "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM="
     },
     "public-encrypt": {
       "version": "4.0.2",
@@ -9929,8 +10379,7 @@
     "qs": {
       "version": "6.5.1",
       "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.1.tgz",
-      "integrity": "sha512-eRzhrN1WSINYCDCbrz796z37LOe3m5tmW7RQf6oBntukAG1nmovJvhnwHHRMAfeoItc1m2Hk02WER2aQ/iqs+A==",
-      "dev": true
+      "integrity": "sha512-eRzhrN1WSINYCDCbrz796z37LOe3m5tmW7RQf6oBntukAG1nmovJvhnwHHRMAfeoItc1m2Hk02WER2aQ/iqs+A=="
     },
     "quad-indices": {
       "version": "2.0.1",
@@ -10032,7 +10481,6 @@
       "version": "2.3.2",
       "resolved": "https://registry.yarnpkg.com/raw-body/-/raw-body-2.3.2.tgz",
       "integrity": "sha1-vNYMd9Prk83gBQKVw/N5OJvIj4k=",
-      "dev": true,
       "requires": {
         "bytes": "3.0.0",
         "http-errors": "1.6.2",
@@ -10043,14 +10491,12 @@
         "depd": {
           "version": "1.1.1",
           "resolved": "https://registry.yarnpkg.com/depd/-/depd-1.1.1.tgz",
-          "integrity": "sha1-V4O04cRZ8G+lyif5kfPQbnoxA1k=",
-          "dev": true
+          "integrity": "sha1-V4O04cRZ8G+lyif5kfPQbnoxA1k="
         },
         "http-errors": {
           "version": "1.6.2",
           "resolved": "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.2.tgz",
           "integrity": "sha1-CgAsyFcHGSp+eUbO7cERVfYOxzY=",
-          "dev": true,
           "requires": {
             "depd": "1.1.1",
             "inherits": "2.0.3",
@@ -10061,14 +10507,12 @@
         "iconv-lite": {
           "version": "0.4.19",
           "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz",
-          "integrity": "sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ==",
-          "dev": true
+          "integrity": "sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ=="
         },
         "setprototypeof": {
           "version": "1.0.3",
           "resolved": "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.0.3.tgz",
-          "integrity": "sha1-ZlZ+NwQ+608E2RvWWMDL77VbjgQ=",
-          "dev": true
+          "integrity": "sha1-ZlZ+NwQ+608E2RvWWMDL77VbjgQ="
         }
       }
     },
@@ -10094,6 +10538,22 @@
         "prop-types": "^15.6.0"
       }
     },
+    "react-file-reader-input": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/react-file-reader-input/-/react-file-reader-input-1.1.4.tgz",
+      "integrity": "sha1-1rD1V3k6Oz7iBuqTAoPaNCM3y6g=",
+      "requires": {
+        "karma": "^0.13.22"
+      }
+    },
+    "react-input-autosize": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/react-input-autosize/-/react-input-autosize-2.2.1.tgz",
+      "integrity": "sha512-3+K4CD13iE4lQQ2WlF8PuV5htfmTRLH6MDnfndHM6LuBRszuXnuyIfE7nhSKt8AzRBZ50bu0sAhkNMeS5pxQQA==",
+      "requires": {
+        "prop-types": "^15.5.8"
+      }
+    },
     "react-intl": {
       "version": "2.4.0",
       "resolved": "https://registry.yarnpkg.com/react-intl/-/react-intl-2.4.0.tgz",
@@ -10105,6 +10565,16 @@
         "invariant": "^2.1.1"
       }
     },
+    "react-select": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/react-select/-/react-select-1.3.0.tgz",
+      "integrity": "sha512-g/QAU1HZrzSfxkwMAo/wzi6/ezdWye302RGZevsATec07hI/iSxcpB1hejFIp7V63DJ8mwuign6KmB3VjdlinQ==",
+      "requires": {
+        "classnames": "^2.2.4",
+        "prop-types": "^15.5.8",
+        "react-input-autosize": "^2.1.2"
+      }
+    },
     "read-chunk": {
       "version": "2.1.0",
       "resolved": "https://registry.yarnpkg.com/read-chunk/-/read-chunk-2.1.0.tgz",
@@ -10171,7 +10641,6 @@
       "version": "2.3.6",
       "resolved": "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz",
       "integrity": "sha1-sRwn2IuP8fvgcGQ8+UsMea4bCq8=",
-      "dev": true,
       "requires": {
         "core-util-is": "~1.0.0",
         "inherits": "~2.0.3",
@@ -10185,14 +10654,12 @@
         "isarray": {
           "version": "1.0.0",
           "resolved": "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz",
-          "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=",
-          "dev": true
+          "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
         },
         "string_decoder": {
           "version": "1.1.1",
           "resolved": "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz",
           "integrity": "sha1-nPFhG6YmhdcDCunkujQUnDrwP8g=",
-          "dev": true,
           "requires": {
             "safe-buffer": "~5.1.0"
           }
@@ -10203,7 +10670,6 @@
       "version": "2.1.0",
       "resolved": "https://registry.yarnpkg.com/readdirp/-/readdirp-2.1.0.tgz",
       "integrity": "sha1-TtCtBg3zBzMAxIRANz9y0cxkLXg=",
-      "dev": true,
       "requires": {
         "graceful-fs": "^4.1.2",
         "minimatch": "^3.0.2",
@@ -10563,8 +11029,7 @@
     "requires-port": {
       "version": "1.0.0",
       "resolved": "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz",
-      "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=",
-      "dev": true
+      "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8="
     },
     "resolve": {
       "version": "1.8.1",
@@ -10643,7 +11108,6 @@
       "version": "2.6.2",
       "resolved": "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.2.tgz",
       "integrity": "sha1-LtgVDSShbqhlHm1u8PR8QVjOejY=",
-      "dev": true,
       "requires": {
         "glob": "^7.0.5"
       }
@@ -10696,8 +11160,7 @@
     "safe-buffer": {
       "version": "5.1.2",
       "resolved": "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
-      "integrity": "sha1-mR7GnSluAxN0fVm9/St0XDX4go0=",
-      "dev": true
+      "integrity": "sha1-mR7GnSluAxN0fVm9/St0XDX4go0="
     },
     "safe-regex": {
       "version": "1.1.0",
@@ -10874,6 +11337,11 @@
       "resolved": "https://registry.yarnpkg.com/sdp/-/sdp-2.7.4.tgz",
       "integrity": "sha1-ysdrDi8W9VJD0lvAQy9ru1SIv8E="
     },
+    "select": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz",
+      "integrity": "sha1-DnNQrN7ICxEIUoeG7B1EGNEbOW0="
+    },
     "select-hose": {
       "version": "2.0.0",
       "resolved": "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz",
@@ -10966,8 +11434,7 @@
     "set-immediate-shim": {
       "version": "1.0.1",
       "resolved": "https://registry.yarnpkg.com/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz",
-      "integrity": "sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E=",
-      "dev": true
+      "integrity": "sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E="
     },
     "set-value": {
       "version": "2.0.0",
@@ -10989,8 +11456,7 @@
     "setprototypeof": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz",
-      "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==",
-      "dev": true
+      "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ=="
     },
     "sha.js": {
       "version": "2.4.11",
@@ -11142,6 +11608,128 @@
         "kind-of": "^3.2.0"
       }
     },
+    "socket.io": {
+      "version": "1.7.4",
+      "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-1.7.4.tgz",
+      "integrity": "sha1-L37O3DORvy1cc+KR/iM+bjTU3QA=",
+      "requires": {
+        "debug": "2.3.3",
+        "engine.io": "~1.8.4",
+        "has-binary": "0.1.7",
+        "object-assign": "4.1.0",
+        "socket.io-adapter": "0.5.0",
+        "socket.io-client": "1.7.4",
+        "socket.io-parser": "2.3.1"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "2.3.3",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-2.3.3.tgz",
+          "integrity": "sha1-QMRT5n5uE8kB3ewxeviYbNqe/4w=",
+          "requires": {
+            "ms": "0.7.2"
+          }
+        },
+        "ms": {
+          "version": "0.7.2",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz",
+          "integrity": "sha1-riXPJRKziFodldfwN4aNhDESR2U="
+        },
+        "object-assign": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.0.tgz",
+          "integrity": "sha1-ejs9DpgGPUP0wD8uiubNUahog6A="
+        }
+      }
+    },
+    "socket.io-adapter": {
+      "version": "0.5.0",
+      "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-0.5.0.tgz",
+      "integrity": "sha1-y21LuL7IHhB4uZZ3+c7QBGBmu4s=",
+      "requires": {
+        "debug": "2.3.3",
+        "socket.io-parser": "2.3.1"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "2.3.3",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-2.3.3.tgz",
+          "integrity": "sha1-QMRT5n5uE8kB3ewxeviYbNqe/4w=",
+          "requires": {
+            "ms": "0.7.2"
+          }
+        },
+        "ms": {
+          "version": "0.7.2",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz",
+          "integrity": "sha1-riXPJRKziFodldfwN4aNhDESR2U="
+        }
+      }
+    },
+    "socket.io-client": {
+      "version": "1.7.4",
+      "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-1.7.4.tgz",
+      "integrity": "sha1-7J+CA1btme9tNX8HVtZIcXvdQoE=",
+      "requires": {
+        "backo2": "1.0.2",
+        "component-bind": "1.0.0",
+        "component-emitter": "1.2.1",
+        "debug": "2.3.3",
+        "engine.io-client": "~1.8.4",
+        "has-binary": "0.1.7",
+        "indexof": "0.0.1",
+        "object-component": "0.0.3",
+        "parseuri": "0.0.5",
+        "socket.io-parser": "2.3.1",
+        "to-array": "0.1.4"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "2.3.3",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-2.3.3.tgz",
+          "integrity": "sha1-QMRT5n5uE8kB3ewxeviYbNqe/4w=",
+          "requires": {
+            "ms": "0.7.2"
+          }
+        },
+        "ms": {
+          "version": "0.7.2",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz",
+          "integrity": "sha1-riXPJRKziFodldfwN4aNhDESR2U="
+        }
+      }
+    },
+    "socket.io-parser": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-2.3.1.tgz",
+      "integrity": "sha1-3VMgJRA85Clpcya+/WQAX8/ltKA=",
+      "requires": {
+        "component-emitter": "1.1.2",
+        "debug": "2.2.0",
+        "isarray": "0.0.1",
+        "json3": "3.3.2"
+      },
+      "dependencies": {
+        "component-emitter": {
+          "version": "1.1.2",
+          "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.1.2.tgz",
+          "integrity": "sha1-KWWU8nU9qmOZbSrwjRWpURbJrsM="
+        },
+        "debug": {
+          "version": "2.2.0",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
+          "integrity": "sha1-+HBX6ZWxofauaklgZkE3vFbwOdo=",
+          "requires": {
+            "ms": "0.7.1"
+          }
+        },
+        "ms": {
+          "version": "0.7.1",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz",
+          "integrity": "sha1-nNE8A62/8ltl7/3nzoZO6VIBcJg="
+        }
+      }
+    },
     "sockjs": {
       "version": "0.3.19",
       "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.19.tgz",
@@ -11373,8 +11961,7 @@
     "statuses": {
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.4.0.tgz",
-      "integrity": "sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==",
-      "dev": true
+      "integrity": "sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew=="
     },
     "stdout-stream": {
       "version": "1.4.1",
@@ -11931,8 +12518,8 @@
       }
     },
     "super-hands": {
-      "version": "github:mozillareality/aframe-super-hands-component#f8f9781d8b4c487bb544b3986000e85ed5f82fcc",
-      "from": "super-hands@github:mozillareality/aframe-super-hands-component#f8f9781d8b4c487bb544b3986000e85ed5f82fcc"
+      "version": "github:mozillareality/aframe-super-hands-component#68d022fd24c6c986ec3af09a3e88b75323e9803f",
+      "from": "github:mozillareality/aframe-super-hands-component#feature/drawing"
     },
     "supports-color": {
       "version": "5.4.0",
@@ -12087,15 +12674,24 @@
         "setimmediate": "^1.0.4"
       }
     },
+    "tiny-emitter": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.0.2.tgz",
+      "integrity": "sha512-2NM0auVBGft5tee/OxP4PI3d8WItkDM+fPnaRAVo6xTDI2knbz9eC5ArWGqtGlYqiH3RU5yMpdyTTO7MguC4ow=="
+    },
     "tmp": {
       "version": "0.0.33",
       "resolved": "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz",
       "integrity": "sha1-bTQzWIl2jSGyvNoKonfO07G/rfk=",
-      "dev": true,
       "requires": {
         "os-tmpdir": "~1.0.2"
       }
     },
+    "to-array": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/to-array/-/to-array-0.1.4.tgz",
+      "integrity": "sha1-F+bBH3PdTz10zaek/zI46a2b+JA="
+    },
     "to-arraybuffer": {
       "version": "1.0.1",
       "resolved": "https://registry.yarnpkg.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz",
@@ -12269,7 +12865,6 @@
       "version": "1.6.16",
       "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.16.tgz",
       "integrity": "sha512-HRkVv/5qY2G6I8iab9cI7v1bOIdhm94dVjQCPFElW9W+3GeDOSHmy2EBYe4VTApuzolPcmgFTN3ftVJRKR2J9Q==",
-      "dev": true,
       "requires": {
         "media-typer": "0.3.0",
         "mime-types": "~2.1.18"
@@ -12351,6 +12946,11 @@
         }
       }
     },
+    "ultron": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/ultron/-/ultron-1.0.2.tgz",
+      "integrity": "sha1-rOEWq1V80Zc4ak6I9GhTeMiy5Po="
+    },
     "underscore": {
       "version": "1.5.2",
       "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.5.2.tgz",
@@ -12490,8 +13090,7 @@
     "unpipe": {
       "version": "1.0.0",
       "resolved": "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz",
-      "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=",
-      "dev": true
+      "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw="
     },
     "unset-value": {
       "version": "1.0.0",
@@ -12651,6 +13250,15 @@
       "integrity": "sha1-1QyMrHmhn7wg8pEfVuuXP04QBw8=",
       "dev": true
     },
+    "useragent": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/useragent/-/useragent-2.3.0.tgz",
+      "integrity": "sha512-4AoH4pxuSvHCjqLO04sU6U/uE65BYza8l/KKBS0b0hnUPWi+cQ2BpeTEwejCSx9SPV5/U03nniDTrWx5NrmKdw==",
+      "requires": {
+        "lru-cache": "4.1.x",
+        "tmp": "0.0.x"
+      }
+    },
     "util": {
       "version": "0.10.4",
       "resolved": "https://registry.yarnpkg.com/util/-/util-0.10.4.tgz",
@@ -12684,8 +13292,7 @@
     "utils-merge": {
       "version": "1.0.1",
       "resolved": "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz",
-      "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=",
-      "dev": true
+      "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM="
     },
     "uuid": {
       "version": "3.3.2",
@@ -12802,6 +13409,11 @@
         "indexof": "0.0.1"
       }
     },
+    "void-elements": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz",
+      "integrity": "sha1-wGavtYK7HLQSjWDqkjkulNXp2+w="
+    },
     "watchpack": {
       "version": "1.6.0",
       "resolved": "https://registry.yarnpkg.com/watchpack/-/watchpack-1.6.0.tgz",
@@ -13294,6 +13906,20 @@
         "slide": "^1.1.5"
       }
     },
+    "ws": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-1.1.5.tgz",
+      "integrity": "sha512-o3KqipXNUdS7wpQzBHSe180lBGO60SoK0yVo3CYJgb2MkobuWuBX6dhkYP5ORCLd55y+SaflMOV5fqAB53ux4w==",
+      "requires": {
+        "options": ">=0.0.5",
+        "ultron": "1.0.x"
+      }
+    },
+    "wtf-8": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/wtf-8/-/wtf-8-1.0.0.tgz",
+      "integrity": "sha1-OS2LotDxw00e4tYw8V0O+2jhBIo="
+    },
     "x-is-string": {
       "version": "0.1.0",
       "resolved": "https://registry.npmjs.org/x-is-string/-/x-is-string-0.1.0.tgz",
@@ -13350,6 +13976,11 @@
       "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz",
       "integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0="
     },
+    "xmlhttprequest-ssl": {
+      "version": "1.5.3",
+      "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.3.tgz",
+      "integrity": "sha1-GFqIjATspGw+QHDZn3tJ3jUomS0="
+    },
     "xregexp": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/xregexp/-/xregexp-4.0.0.tgz",
@@ -13479,6 +14110,11 @@
         }
       }
     },
+    "yeast": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/yeast/-/yeast-0.1.2.tgz",
+      "integrity": "sha1-AI4G2AlDIMNy28L47XagymyKxBk="
+    },
     "yeoman-environment": {
       "version": "2.3.1",
       "resolved": "https://registry.yarnpkg.com/yeoman-environment/-/yeoman-environment-2.3.1.tgz",
diff --git a/package.json b/package.json
index 2fcd8dd780347a943a5c939048eadeff76c90258..b0f7710f6b8bdcf1c4606b5ca7bc5e44bf815e90 100644
--- a/package.json
+++ b/package.json
@@ -19,17 +19,19 @@
     "prettier": "prettier --write '*.js' 'src/**/*.js'",
     "lint:js": "eslint '*.js' 'scripts/**/*.js' 'src/**/*.js'",
     "lint:html": "htmlhint 'src/**/*.html'",
-    "lint": "npm run lint:js && npm run lint:html"
+    "lint": "npm run lint:js && npm run lint:html",
+    "test": "npm run lint && npm run build"
   },
   "dependencies": {
     "@fortawesome/fontawesome-svg-core": "^1.2.2",
     "@fortawesome/free-solid-svg-icons": "^5.2.0",
     "@fortawesome/react-fontawesome": "^0.1.0",
-    "aframe": "github:aframevr/aframe#1be48d9204f0919d6362ef4c4dfa955e4ef64439",
+    "aframe": "github:mozillareality/aframe#bugfix/oculus-go-controller-reconnect-pre-e0c8ff7",
     "aframe-billboard-component": "^1.0.0",
     "aframe-input-mapping-component": "github:mozillareality/aframe-input-mapping-component#hubs/master",
+    "aframe-inspector": "^0.8.3",
     "aframe-motion-capture-components": "github:mozillareality/aframe-motion-capture-components#1ca616fa67b627e447b23b35a09da175d8387668",
-    "aframe-physics-extras": "^0.1.3",
+    "aframe-physics-extras": "github:mozillareality/aframe-physics-extras#bugfix/physics-collider-world",
     "aframe-physics-system": "github:mozillareality/aframe-physics-system#ecc5c9c533d6d9c71f8d6453ab961ed074d44b1c",
     "aframe-rounded": "^1.0.3",
     "aframe-slice9-component": "^1.0.0",
@@ -43,7 +45,7 @@
     "jsonschema": "^1.2.2",
     "jszip": "^3.1.5",
     "moving-average": "^1.0.0",
-    "naf-janus-adapter": "^0.11.0",
+    "naf-janus-adapter": "^0.12.0",
     "networked-aframe": "github:mozillareality/networked-aframe#master",
     "nipplejs": "github:mozillareality/nipplejs#mr-social-client/master",
     "phoenix": "^1.3.0",
@@ -52,7 +54,7 @@
     "react-dom": "^16.1.1",
     "react-intl": "^2.4.0",
     "screenfull": "^3.3.2",
-    "super-hands": "github:mozillareality/aframe-super-hands-component#f8f9781d8b4c487bb544b3986000e85ed5f82fcc",
+    "super-hands": "github:mozillareality/aframe-super-hands-component#feature/drawing",
     "three": "github:mozillareality/three.js#8b1886c384371c3e6305b757d1db7577c5201a9b",
     "three-pathfinding": "^0.7.0",
     "three-to-cannon": "1.3.0",
diff --git a/scripts/bot/package-lock.json b/scripts/bot/package-lock.json
index fc2a26a35abb1f21b7e1d9801159a6eb8f8c69f3..6b4096f478cdf40a22477eb20dda838344af0ecf 100644
--- a/scripts/bot/package-lock.json
+++ b/scripts/bot/package-lock.json
@@ -4,106 +4,108 @@
   "lockfileVersion": 1,
   "requires": true,
   "dependencies": {
-    "minimatch": {
-      "version": "3.0.4",
-      "resolved": "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz",
-      "integrity": "sha1-UWbihkV/AzBgZL5Ul+jbsMPTIIM=",
-      "requires": {
-        "brace-expansion": "1.1.11"
-      }
-    },
-    "minimist": {
-      "version": "0.0.8",
-      "resolved": "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz",
-      "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0="
-    },
-    "yauzl": {
-      "version": "2.4.1",
-      "resolved": "https://registry.yarnpkg.com/yauzl/-/yauzl-2.4.1.tgz",
-      "integrity": "sha1-lSj0QtqxsihOWLQ3m7GU4i4MQAU=",
-      "requires": {
-        "fd-slicer": "1.0.1"
-      }
-    },
-    "mkdirp": {
-      "version": "0.5.1",
-      "resolved": "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz",
-      "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
+    "agent-base": {
+      "version": "4.2.0",
+      "resolved": "https://registry.yarnpkg.com/agent-base/-/agent-base-4.2.0.tgz",
+      "integrity": "sha1-mDi1wzkrliutAx5qTF4QJKvsRc4=",
+      "dev": true,
       "requires": {
-        "minimist": "0.0.8"
+        "es6-promisify": "^5.0.0"
       }
     },
     "async-limiter": {
       "version": "1.0.0",
       "resolved": "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.0.tgz",
-      "integrity": "sha1-ePrtjD0HSrgfIrTphdeehzj3IPg="
+      "integrity": "sha1-ePrtjD0HSrgfIrTphdeehzj3IPg=",
+      "dev": true
     },
-    "ms": {
-      "version": "2.0.0",
-      "resolved": "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz",
-      "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+    "balanced-match": {
+      "version": "1.0.0",
+      "resolved": "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz",
+      "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
+      "dev": true
     },
     "brace-expansion": {
       "version": "1.1.11",
       "resolved": "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz",
       "integrity": "sha1-PH/L9SnYcibz0vUrlm/1Jx60Qd0=",
+      "dev": true,
       "requires": {
-        "balanced-match": "1.0.0",
+        "balanced-match": "^1.0.0",
         "concat-map": "0.0.1"
       }
     },
-    "object-assign": {
-      "version": "4.1.1",
-      "resolved": "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz",
-      "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
+    "buffer-from": {
+      "version": "1.1.0",
+      "resolved": "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.0.tgz",
+      "integrity": "sha1-h/yqOimDWOCt5uRCz86EB0DRrQQ=",
+      "dev": true
     },
     "concat-map": {
       "version": "0.0.1",
       "resolved": "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz",
-      "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
+      "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
+      "dev": true
     },
-    "once": {
-      "version": "1.4.0",
-      "resolved": "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz",
-      "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
+    "concat-stream": {
+      "version": "1.6.2",
+      "resolved": "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz",
+      "integrity": "sha1-kEvfGUzTEi/Gdcd/xKw9T/D9GjQ=",
+      "dev": true,
       "requires": {
-        "wrappy": "1.0.2"
+        "buffer-from": "^1.0.0",
+        "inherits": "^2.0.3",
+        "readable-stream": "^2.2.2",
+        "typedarray": "^0.0.6"
       }
     },
     "core-util-is": {
       "version": "1.0.2",
       "resolved": "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz",
-      "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac="
+      "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
+      "dev": true
     },
-    "path-is-absolute": {
-      "version": "1.0.1",
-      "resolved": "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
-      "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
+    "debug": {
+      "version": "2.6.9",
+      "resolved": "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz",
+      "integrity": "sha1-XRKFFd8TT/Mn6QpMk/Tgd6U2NB8=",
+      "dev": true,
+      "requires": {
+        "ms": "2.0.0"
+      }
     },
     "decode-uri-component": {
       "version": "0.2.0",
       "resolved": "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
-      "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU="
+      "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=",
+      "dev": true
     },
-    "pend": {
-      "version": "1.2.0",
-      "resolved": "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz",
-      "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA="
+    "docopt": {
+      "version": "0.6.2",
+      "resolved": "https://registry.yarnpkg.com/docopt/-/docopt-0.6.2.tgz",
+      "integrity": "sha1-so6eIiDaXsSffqW7JKR3h0Be6xE=",
+      "dev": true
     },
     "es6-promise": {
       "version": "4.2.4",
       "resolved": "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.4.tgz",
-      "integrity": "sha1-3EIhwrFlGHYL2MOaUtjzVvwA7Sk="
+      "integrity": "sha1-3EIhwrFlGHYL2MOaUtjzVvwA7Sk=",
+      "dev": true
     },
-    "process-nextick-args": {
-      "version": "2.0.0",
-      "resolved": "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.0.tgz",
-      "integrity": "sha1-o31zL0JxtKsa0HDTVQjoKQeI/6o="
+    "es6-promisify": {
+      "version": "5.0.0",
+      "resolved": "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-5.0.0.tgz",
+      "integrity": "sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=",
+      "dev": true,
+      "requires": {
+        "es6-promise": "^4.0.3"
+      }
     },
     "extract-zip": {
       "version": "1.6.7",
       "resolved": "https://registry.yarnpkg.com/extract-zip/-/extract-zip-1.6.7.tgz",
       "integrity": "sha1-qEC0uK9kAyZMjbV/Txp0Mz74H+k=",
+      "dev": true,
       "requires": {
         "concat-stream": "1.6.2",
         "debug": "2.6.9",
@@ -111,229 +113,274 @@
         "yauzl": "2.4.1"
       }
     },
-    "progress": {
-      "version": "2.0.0",
-      "resolved": "https://registry.yarnpkg.com/progress/-/progress-2.0.0.tgz",
-      "integrity": "sha1-ihvjZr+Pwj2yvSPxDG/pILQ4nR8="
+    "fd-slicer": {
+      "version": "1.0.1",
+      "resolved": "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.0.1.tgz",
+      "integrity": "sha1-i1vL2ewyfFBBv5qwI/1nUPEXfmU=",
+      "dev": true,
+      "requires": {
+        "pend": "~1.2.0"
+      }
     },
     "fs.realpath": {
       "version": "1.0.0",
       "resolved": "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz",
-      "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
+      "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
+      "dev": true
     },
-    "proxy-from-env": {
-      "version": "1.0.0",
-      "resolved": "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.0.0.tgz",
-      "integrity": "sha1-M8UDmPcOp+uW0h97gXYwpVeRx+4="
+    "glob": {
+      "version": "7.1.2",
+      "resolved": "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz",
+      "integrity": "sha1-wZyd+aAocC1nhhI4SmVSQExjbRU=",
+      "dev": true,
+      "requires": {
+        "fs.realpath": "^1.0.0",
+        "inflight": "^1.0.4",
+        "inherits": "2",
+        "minimatch": "^3.0.4",
+        "once": "^1.3.0",
+        "path-is-absolute": "^1.0.0"
+      }
     },
     "https-proxy-agent": {
       "version": "2.2.1",
       "resolved": "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-2.2.1.tgz",
       "integrity": "sha1-UVUpcPoE1yPgTFbQQXjD+SWSu8A=",
+      "dev": true,
       "requires": {
-        "agent-base": "4.2.0",
-        "debug": "3.1.0"
+        "agent-base": "^4.1.0",
+        "debug": "^3.1.0"
       },
       "dependencies": {
         "debug": {
           "version": "3.1.0",
           "resolved": "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz",
           "integrity": "sha1-W7WgZyYotkFJVmuhaBnmFRjGcmE=",
+          "dev": true,
           "requires": {
             "ms": "2.0.0"
           }
         }
       }
     },
-    "puppeteer": {
-      "version": "1.3.0",
-      "resolved": "https://registry.yarnpkg.com/puppeteer/-/puppeteer-1.3.0.tgz",
-      "integrity": "sha1-9XHF8nFTyhZKgYjmMozi5JRoePM=",
-      "requires": {
-        "debug": "2.6.9",
-        "extract-zip": "1.6.7",
-        "https-proxy-agent": "2.2.1",
-        "mime": "1.6.0",
-        "progress": "2.0.0",
-        "proxy-from-env": "1.0.0",
-        "rimraf": "2.6.2",
-        "ws": "3.3.3"
-      }
-    },
     "inflight": {
       "version": "1.0.6",
       "resolved": "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz",
       "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
+      "dev": true,
       "requires": {
-        "once": "1.4.0",
-        "wrappy": "1.0.2"
+        "once": "^1.3.0",
+        "wrappy": "1"
+      }
+    },
+    "inherits": {
+      "version": "2.0.3",
+      "resolved": "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz",
+      "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=",
+      "dev": true
+    },
+    "isarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz",
+      "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=",
+      "dev": true
+    },
+    "mime": {
+      "version": "1.6.0",
+      "resolved": "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz",
+      "integrity": "sha1-Ms2eXGRVO9WNGaVor0Uqz/BJgbE=",
+      "dev": true
+    },
+    "minimatch": {
+      "version": "3.0.4",
+      "resolved": "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz",
+      "integrity": "sha1-UWbihkV/AzBgZL5Ul+jbsMPTIIM=",
+      "dev": true,
+      "requires": {
+        "brace-expansion": "^1.1.7"
+      }
+    },
+    "minimist": {
+      "version": "0.0.8",
+      "resolved": "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz",
+      "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=",
+      "dev": true
+    },
+    "mkdirp": {
+      "version": "0.5.1",
+      "resolved": "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz",
+      "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
+      "dev": true,
+      "requires": {
+        "minimist": "0.0.8"
+      }
+    },
+    "ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
+      "dev": true
+    },
+    "object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
+      "dev": true
+    },
+    "once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz",
+      "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
+      "dev": true,
+      "requires": {
+        "wrappy": "1"
+      }
+    },
+    "path-is-absolute": {
+      "version": "1.0.1",
+      "resolved": "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+      "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
+      "dev": true
+    },
+    "pend": {
+      "version": "1.2.0",
+      "resolved": "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz",
+      "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=",
+      "dev": true
+    },
+    "process-nextick-args": {
+      "version": "2.0.0",
+      "resolved": "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.0.tgz",
+      "integrity": "sha1-o31zL0JxtKsa0HDTVQjoKQeI/6o=",
+      "dev": true
+    },
+    "progress": {
+      "version": "2.0.0",
+      "resolved": "https://registry.yarnpkg.com/progress/-/progress-2.0.0.tgz",
+      "integrity": "sha1-ihvjZr+Pwj2yvSPxDG/pILQ4nR8=",
+      "dev": true
+    },
+    "proxy-from-env": {
+      "version": "1.0.0",
+      "resolved": "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.0.0.tgz",
+      "integrity": "sha1-M8UDmPcOp+uW0h97gXYwpVeRx+4=",
+      "dev": true
+    },
+    "puppeteer": {
+      "version": "1.3.0",
+      "resolved": "https://registry.yarnpkg.com/puppeteer/-/puppeteer-1.3.0.tgz",
+      "integrity": "sha1-9XHF8nFTyhZKgYjmMozi5JRoePM=",
+      "dev": true,
+      "requires": {
+        "debug": "^2.6.8",
+        "extract-zip": "^1.6.5",
+        "https-proxy-agent": "^2.1.0",
+        "mime": "^1.3.4",
+        "progress": "^2.0.0",
+        "proxy-from-env": "^1.0.0",
+        "rimraf": "^2.6.1",
+        "ws": "^3.0.0"
       }
     },
     "query-string": {
       "version": "5.1.1",
       "resolved": "https://registry.yarnpkg.com/query-string/-/query-string-5.1.1.tgz",
       "integrity": "sha1-p4wBK3HBfgXy4/ojGd0zBoLvs8s=",
+      "dev": true,
       "requires": {
-        "decode-uri-component": "0.2.0",
-        "object-assign": "4.1.1",
-        "strict-uri-encode": "1.1.0"
+        "decode-uri-component": "^0.2.0",
+        "object-assign": "^4.1.0",
+        "strict-uri-encode": "^1.0.0"
       }
     },
-    "isarray": {
-      "version": "1.0.0",
-      "resolved": "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz",
-      "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
-    },
     "readable-stream": {
       "version": "2.3.6",
       "resolved": "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz",
       "integrity": "sha1-sRwn2IuP8fvgcGQ8+UsMea4bCq8=",
+      "dev": true,
       "requires": {
-        "core-util-is": "1.0.2",
-        "inherits": "2.0.3",
-        "isarray": "1.0.0",
-        "process-nextick-args": "2.0.0",
-        "safe-buffer": "5.1.2",
-        "string_decoder": "1.1.1",
-        "util-deprecate": "1.0.2"
-      }
-    },
-    "agent-base": {
-      "version": "4.2.0",
-      "resolved": "https://registry.yarnpkg.com/agent-base/-/agent-base-4.2.0.tgz",
-      "integrity": "sha1-mDi1wzkrliutAx5qTF4QJKvsRc4=",
-      "requires": {
-        "es6-promisify": "5.0.0"
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
       }
     },
     "rimraf": {
       "version": "2.6.2",
       "resolved": "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.2.tgz",
       "integrity": "sha1-LtgVDSShbqhlHm1u8PR8QVjOejY=",
+      "dev": true,
       "requires": {
-        "glob": "7.1.2"
+        "glob": "^7.0.5"
       }
     },
-    "buffer-from": {
-      "version": "1.1.0",
-      "resolved": "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.0.tgz",
-      "integrity": "sha1-h/yqOimDWOCt5uRCz86EB0DRrQQ="
-    },
     "safe-buffer": {
       "version": "5.1.2",
       "resolved": "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
-      "integrity": "sha1-mR7GnSluAxN0fVm9/St0XDX4go0="
-    },
-    "debug": {
-      "version": "2.6.9",
-      "resolved": "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz",
-      "integrity": "sha1-XRKFFd8TT/Mn6QpMk/Tgd6U2NB8=",
-      "requires": {
-        "ms": "2.0.0"
-      }
+      "integrity": "sha1-mR7GnSluAxN0fVm9/St0XDX4go0=",
+      "dev": true
     },
     "strict-uri-encode": {
       "version": "1.1.0",
       "resolved": "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz",
-      "integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM="
-    },
-    "es6-promisify": {
-      "version": "5.0.0",
-      "resolved": "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-5.0.0.tgz",
-      "integrity": "sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=",
-      "requires": {
-        "es6-promise": "4.2.4"
-      }
+      "integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=",
+      "dev": true
     },
     "string_decoder": {
       "version": "1.1.1",
       "resolved": "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz",
       "integrity": "sha1-nPFhG6YmhdcDCunkujQUnDrwP8g=",
+      "dev": true,
       "requires": {
-        "safe-buffer": "5.1.2"
-      }
-    },
-    "glob": {
-      "version": "7.1.2",
-      "resolved": "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz",
-      "integrity": "sha1-wZyd+aAocC1nhhI4SmVSQExjbRU=",
-      "requires": {
-        "fs.realpath": "1.0.0",
-        "inflight": "1.0.6",
-        "inherits": "2.0.3",
-        "minimatch": "3.0.4",
-        "once": "1.4.0",
-        "path-is-absolute": "1.0.1"
+        "safe-buffer": "~5.1.0"
       }
     },
     "typedarray": {
       "version": "0.0.6",
       "resolved": "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz",
-      "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c="
-    },
-    "inherits": {
-      "version": "2.0.3",
-      "resolved": "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz",
-      "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
-    },
-    "balanced-match": {
-      "version": "1.0.0",
-      "resolved": "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz",
-      "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
-    },
-    "ws": {
-      "version": "3.3.3",
-      "resolved": "https://registry.yarnpkg.com/ws/-/ws-3.3.3.tgz",
-      "integrity": "sha1-8c+E/i1ekB686U767OeF8YeiKPI=",
-      "requires": {
-        "async-limiter": "1.0.0",
-        "safe-buffer": "5.1.2",
-        "ultron": "1.1.1"
-      }
+      "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=",
+      "dev": true
     },
-    "wrappy": {
-      "version": "1.0.2",
-      "resolved": "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz",
-      "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
+    "ultron": {
+      "version": "1.1.1",
+      "resolved": "https://registry.yarnpkg.com/ultron/-/ultron-1.1.1.tgz",
+      "integrity": "sha1-n+FTahCmZKZSZqHjzPhf02MCvJw=",
+      "dev": true
     },
     "util-deprecate": {
       "version": "1.0.2",
       "resolved": "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
-      "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
+      "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
+      "dev": true
     },
-    "ultron": {
-      "version": "1.1.1",
-      "resolved": "https://registry.yarnpkg.com/ultron/-/ultron-1.1.1.tgz",
-      "integrity": "sha1-n+FTahCmZKZSZqHjzPhf02MCvJw="
+    "wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
+      "dev": true
     },
-    "concat-stream": {
-      "version": "1.6.2",
-      "resolved": "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz",
-      "integrity": "sha1-kEvfGUzTEi/Gdcd/xKw9T/D9GjQ=",
+    "ws": {
+      "version": "3.3.3",
+      "resolved": "https://registry.yarnpkg.com/ws/-/ws-3.3.3.tgz",
+      "integrity": "sha1-8c+E/i1ekB686U767OeF8YeiKPI=",
+      "dev": true,
       "requires": {
-        "buffer-from": "1.1.0",
-        "inherits": "2.0.3",
-        "readable-stream": "2.3.6",
-        "typedarray": "0.0.6"
+        "async-limiter": "~1.0.0",
+        "safe-buffer": "~5.1.0",
+        "ultron": "~1.1.0"
       }
     },
-    "mime": {
-      "version": "1.6.0",
-      "resolved": "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz",
-      "integrity": "sha1-Ms2eXGRVO9WNGaVor0Uqz/BJgbE="
-    },
-    "fd-slicer": {
-      "version": "1.0.1",
-      "resolved": "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.0.1.tgz",
-      "integrity": "sha1-i1vL2ewyfFBBv5qwI/1nUPEXfmU=",
+    "yauzl": {
+      "version": "2.4.1",
+      "resolved": "https://registry.yarnpkg.com/yauzl/-/yauzl-2.4.1.tgz",
+      "integrity": "sha1-lSj0QtqxsihOWLQ3m7GU4i4MQAU=",
+      "dev": true,
       "requires": {
-        "pend": "1.2.0"
+        "fd-slicer": "~1.0.1"
       }
-    },
-    "docopt": {
-      "version": "0.6.2",
-      "resolved": "https://registry.yarnpkg.com/docopt/-/docopt-0.6.2.tgz",
-      "integrity": "sha1-so6eIiDaXsSffqW7JKR3h0Be6xE="
     }
   }
-}
\ No newline at end of file
+}
diff --git a/scripts/bot/run-bot.js b/scripts/bot/run-bot.js
index fbcef11f4121d5cf16f4babadedc58e118075d2d..687f27f8598764a9384dc7023e523bf1e9be6288 100755
--- a/scripts/bot/run-bot.js
+++ b/scripts/bot/run-bot.js
@@ -21,17 +21,13 @@ function log(...objs) {
   console.log.call(null, [new Date().toISOString()].concat(objs).join(" "));
 }
 
-function error(...objs) {
-  console.error.call(null, [new Date().toISOString()].concat(objs).join(" "));
-}
-
 (async () => {
   const browser = await puppeteer.launch({ ignoreHTTPSErrors: true });
   const page = await browser.newPage();
   await page.setBypassCSP(true);
   page.on("console", msg => log("PAGE: ", msg.text()));
-  page.on("error", err => error("ERROR: ", err.toString().split("\n")[0]));
-  page.on("pageerror", err => error("PAGE ERROR: ", err.toString().split("\n")[0]));
+  page.on("error", err => log("ERROR: ", err.toString().split("\n")[0]));
+  page.on("pageerror", err => log("PAGE ERROR: ", err.toString().split("\n")[0]));
 
   const baseUrl = options["--url"] || `https://${options["--host"]}/hub.html`;
 
@@ -80,7 +76,30 @@ function error(...objs) {
           setTimeout(loadFiles, backoff);
         }
       };
+
       await loadFiles();
+
+      // Do a periodic sanity check of the state of the bots.
+      setInterval(async function() {
+        let avatarCounts;
+        try {
+          avatarCounts = await page.evaluate(() => ({
+            connectionCount: Object.keys(NAF.connection.adapter.occupants).length,
+            avatarCount: document.querySelectorAll("[networked-avatar]").length - 1
+          }));
+          log(JSON.stringify(avatarCounts));
+        } catch (e) {
+          // Ignore errors. This usually happens when the page is shutting down.
+        }
+        // Check for more than two connections to allow for a margin where we have a connection but the a-frame
+        // entity has not initialized yet.
+        if (avatarCounts && avatarCounts.connectionCount > 2 && avatarCounts.avatarCount === 0) {
+          // It seems the bots have dog-piled on to a restarting server, so we're going to shut things down and
+          // let the hubs-ops bash script restart us.
+          log("Detected avatar dog-pile. Restarting.");
+          process.exit(1);
+        }
+      }, 60 * 1000);
     } catch (e) {
       log("Navigation error", e.message);
       setTimeout(navigate, 1000);
diff --git a/scripts/hab-build-and-push.sh b/scripts/hab-build-and-push.sh
index 48693732a8902bc487f72eb87e8ff27a02fa9af5..1380235bd31dc889dece549791c550791ec0f178 100755
--- a/scripts/hab-build-and-push.sh
+++ b/scripts/hab-build-and-push.sh
@@ -30,3 +30,4 @@ mv dist/*.html dist/pages
 
 aws s3 sync --acl public-read --cache-control "max-age=31556926" dist/assets "$TARGET_S3_URL/assets"
 aws s3 sync --acl public-read --cache-control "no-cache" --delete dist/pages "$TARGET_S3_URL/pages/latest"
+aws s3 sync --acl public-read --cache-control "no-cache" --delete dist/pages "$TARGET_S3_URL/pages/releases/${BUILD_NUMBER}"
diff --git a/src/assets/hud/spawn_pen-hover.png b/src/assets/hud/spawn_pen-hover.png
new file mode 100644
index 0000000000000000000000000000000000000000..eb298cd91498432098056cd69213919574364dee
Binary files /dev/null and b/src/assets/hud/spawn_pen-hover.png differ
diff --git a/src/assets/hud/spawn_pen.png b/src/assets/hud/spawn_pen.png
new file mode 100644
index 0000000000000000000000000000000000000000..62cfbb279046e04e070e55a14c62399ec22b7be7
Binary files /dev/null and b/src/assets/hud/spawn_pen.png differ
diff --git a/src/assets/hud/spawn_photo-hover.png b/src/assets/hud/spawn_photo-hover.png
new file mode 100755
index 0000000000000000000000000000000000000000..178e75cf5d2ca06e39885eccd93475f96f3175f5
Binary files /dev/null and b/src/assets/hud/spawn_photo-hover.png differ
diff --git a/src/assets/hud/spawn_photo.png b/src/assets/hud/spawn_photo.png
new file mode 100755
index 0000000000000000000000000000000000000000..00473bb8faa20e958effb45ee17b651d730510dc
Binary files /dev/null and b/src/assets/hud/spawn_photo.png differ
diff --git a/src/assets/stylesheets/2d-hud.scss b/src/assets/stylesheets/2d-hud.scss
index edc8b391990a9c81dd67ca2579ffc2991a169ac3..f8b56e42545b757b425135a2e7fb1a21abf29a62 100644
--- a/src/assets/stylesheets/2d-hud.scss
+++ b/src/assets/stylesheets/2d-hud.scss
@@ -1,23 +1,38 @@
 @import 'shared';
 
+:local(.unselectable) {
+  -moz-user-select: none;
+  -webkit-user-select: none;
+  -ms-user-select: none;
+  user-select: none;
+}
+
 :local(.container) {
   position: absolute;
   display: flex;
   justify-content: center;
   align-items: center;
-  height: 80px;
   width: 100%;
-  user-select: none;
 
   &:local(.top) {
     top: 10px;
+    height: 80px;
   }
 
-  &:local(.bottom) {
+  &:local(.column) {
+    flex-direction: column;
     bottom: 20px;
   }
 }
 
+:local(.bottom) {
+  margin-bottom: 20px;
+}
+
+:local(.hide) {
+  display: none;
+}
+
 :local(.panel) {
   display: flex;
   justify-content: space-around;
@@ -42,6 +57,14 @@
   margin-left: -40px;
 }
 
+:local(.panel.up) {
+  border-top-right-radius: 30px;
+  border-top-left-radius: 30px;
+  padding-top: 5px;
+  padding-bottom: 45px;
+  margin-bottom: -40px;
+}
+
 :local(.iconButton) {
   width: 40px;
   height: 40px;
@@ -90,6 +113,13 @@
   background-image: url(../hud/bubble_on-hover.png);
 }
 
+:local(.iconButton.spawn_pen) {
+  background-image: url(../hud/spawn_pen.png);
+}
+:local(.iconButton.spawn_pen:hover) {
+  background-image: url(../hud/spawn_pen-hover.png);
+}
+
 :local(.iconButton.freeze) {
   background-image: url(../hud/freeze_off.png);
 }
@@ -109,3 +139,7 @@
 :local(.iconButton.create-object:hover) {
   background-image: url(../hud/create_object-hover.png);
 }
+
+:local(.iconButton.mobile-media-picker) {
+  background-image: url(../hud/spawn_photo.png);
+}
diff --git a/src/assets/translations.data.json b/src/assets/translations.data.json
index 857c1840a2d5de3ac814f3f19835af1bb0aed8c3..0748867c4cafa14758b02010af6be0e61390c502 100644
--- a/src/assets/translations.data.json
+++ b/src/assets/translations.data.json
@@ -1,5 +1,9 @@
 {
   "en": {
+    "auth.verifying": "Verifying...",
+    "auth.verified-title": "E-Mail Verified!",
+    "auth.verified": "Your email has been verified!",
+    "auth.spoke-verified": "You email has been verified! {br} You can now close this browser tab and return to Spoke.",
     "entry.screen-prefix": "Enter on ",
     "entry.desktop-screen": "Screen",
     "entry.mobile-screen": "Phone",
diff --git a/src/components/css-class.js b/src/components/css-class.js
index 77882e9bce9e2e00c6d02f02677b9388b8110d14..4d4da5fd332a2a06574a43346b5c801b74067138 100644
--- a/src/components/css-class.js
+++ b/src/components/css-class.js
@@ -6,6 +6,7 @@ AFRAME.registerComponent("css-class", {
   schema: {
     type: "string"
   },
+  multiple: true,
   init() {
     this.el.classList.add(this.data);
   },
diff --git a/src/components/cursor-controller.js b/src/components/cursor-controller.js
index c801f76188260c695d7121ef242a00e1d5db4ea9..421b084c309710e9442dd3f44019fdcb0cad94d5 100644
--- a/src/components/cursor-controller.js
+++ b/src/components/cursor-controller.js
@@ -3,39 +3,113 @@ const TARGET_TYPE_INTERACTABLE = 2;
 const TARGET_TYPE_UI = 4;
 const TARGET_TYPE_INTERACTABLE_OR_UI = TARGET_TYPE_INTERACTABLE | TARGET_TYPE_UI;
 
+/**
+ * Manages targeting and physical cursor location. Has the following responsibilities:
+ *
+ * - Tracking which entities in the scene can be targeted by the cursor (`objects`).
+ * - Performing a raycast per-frame or on-demand to identify which entity is being currently targeted.
+ * - Updating the visual presentation and position of the `cursor` entity and `line` component per frame.
+ * - Sending an event when an entity is targeted or un-targeted.
+ */
 AFRAME.registerComponent("cursor-controller", {
-  dependencies: ["raycaster", "line"],
+  dependencies: ["line"],
   schema: {
     cursor: { type: "selector" },
     camera: { type: "selector" },
-    maxDistance: { default: 3 },
-    minDistance: { default: 0 },
+    far: { default: 3 },
+    near: { default: 0 },
     cursorColorHovered: { default: "#2F80ED" },
     cursorColorUnhovered: { default: "#FFFFFF" },
     rayObject: { type: "selector" },
-    useMousePos: { default: true },
-    drawLine: { default: false }
+    drawLine: { default: false },
+    objects: { default: "" }
   },
 
   init: function() {
     this.enabled = true;
-    this.inVR = false;
-    this.isMobile = AFRAME.utils.device.isMobile();
     this.currentTargetType = TARGET_TYPE_NONE;
-    this.currentDistance = this.data.maxDistance;
+    this.currentDistance = this.data.far;
     this.currentDistanceMod = 0;
     this.mousePos = new THREE.Vector2();
     this.wasCursorHovered = false;
-    this.origin = new THREE.Vector3();
-    this.direction = new THREE.Vector3();
-    this.raycasterAttr = this.el.getAttribute("raycaster");
-    this.controllerQuaternion = new THREE.Quaternion();
     this.data.cursor.setAttribute("material", { color: this.data.cursorColorUnhovered });
 
     this._handleCursorLoaded = this._handleCursorLoaded.bind(this);
     this.data.cursor.addEventListener("loaded", this._handleCursorLoaded);
+
+    // raycaster state
+    this.targets = [];
+    this.intersection = null;
+    this.raycaster = new THREE.Raycaster();
+    this.setDirty = this.setDirty.bind(this);
+    this.dirty = true;
+  },
+
+  update: function() {
+    this.raycaster.far = this.data.far;
+    this.raycaster.near = this.data.near;
+    this.setDirty();
+  },
+
+  play: function() {
+    this.observer = new MutationObserver(this.setDirty);
+    this.observer.observe(this.el.sceneEl, { childList: true, attributes: true, subtree: true });
+    this.el.sceneEl.addEventListener("object3dset", this.setDirty);
+    this.el.sceneEl.addEventListener("object3dremove", this.setDirty);
+  },
+
+  pause: function() {
+    this.observer.disconnect();
+    this.el.sceneEl.removeEventListener("object3dset", this.setDirty);
+    this.el.sceneEl.removeEventListener("object3dremove", this.setDirty);
+  },
+
+  setDirty: function() {
+    this.dirty = true;
   },
 
+  populateEntities: function(selector, target) {
+    target.length = 0;
+    const els = this.data.objects ? this.el.sceneEl.querySelectorAll(this.data.objects) : this.el.sceneEl.children;
+    for (let i = 0; i < els.length; i++) {
+      if (els[i].object3D) {
+        target.push(els[i].object3D);
+      }
+    }
+  },
+
+  emitIntersectionEvents: function(prevIntersection, currIntersection) {
+    // if we are now intersecting something, and previously we were intersecting nothing or something else
+    if (currIntersection && (!prevIntersection || currIntersection.object.el !== prevIntersection.object.el)) {
+      this.data.cursor.emit("raycaster-intersection", { el: currIntersection.object.el });
+    }
+    // if we were intersecting something, but now we are intersecting nothing or something else
+    if (prevIntersection && (!currIntersection || currIntersection.object.el !== prevIntersection.object.el)) {
+      this.data.cursor.emit("raycaster-intersection-cleared", { el: prevIntersection.object.el });
+    }
+  },
+
+  performRaycast: (function() {
+    const rayObjectRotation = new THREE.Quaternion();
+    const rawIntersections = [];
+    return function performRaycast(targets) {
+      if (this.data.rayObject) {
+        const rayObject = this.data.rayObject.object3D;
+        rayObject.updateMatrixWorld();
+        rayObjectRotation.setFromRotationMatrix(rayObject.matrixWorld);
+        this.raycaster.ray.origin.setFromMatrixPosition(rayObject.matrixWorld);
+        this.raycaster.ray.direction.set(0, 0, -1).applyQuaternion(rayObjectRotation);
+      } else {
+        this.raycaster.setFromCamera(this.mousePos, this.data.camera.components.camera.camera); // camera
+      }
+      const prevIntersection = this.intersection;
+      rawIntersections.length = 0;
+      this.raycaster.intersectObjects(targets, true, rawIntersections);
+      this.intersection = rawIntersections.find(x => x.object.el);
+      this.emitIntersectionEvents(prevIntersection, this.intersection);
+    };
+  })(),
+
   enable: function() {
     this.enabled = true;
   },
@@ -45,14 +119,7 @@ AFRAME.registerComponent("cursor-controller", {
     this.setCursorVisibility(false);
   },
 
-  updateRay: function() {
-    this.raycasterAttr.origin = this.origin;
-    this.raycasterAttr.direction = this.direction;
-    this.el.setAttribute("raycaster", this.raycasterAttr, true);
-  },
-
   tick: (() => {
-    const rayObjectRotation = new THREE.Quaternion();
     const cameraPos = new THREE.Vector3();
 
     return function() {
@@ -60,27 +127,20 @@ AFRAME.registerComponent("cursor-controller", {
         return;
       }
 
-      if (this.data.useMousePos) {
-        this.setRaycasterWithMousePos();
-      } else {
-        const rayObject = this.data.rayObject.object3D;
-        rayObjectRotation.setFromRotationMatrix(rayObject.matrixWorld);
-        this.direction
-          .set(0, 0, -1)
-          .applyQuaternion(rayObjectRotation)
-          .normalize();
-        this.origin.setFromMatrixPosition(rayObject.matrixWorld);
-        this.updateRay();
+      if (this.dirty) {
+        this.populateEntities(this.data.objects, this.targets);
+        this.dirty = false;
       }
 
-      const isGrabbing = this.data.cursor.components["super-hands"].state.has("grab-start");
-      if (isGrabbing) {
+      this.performRaycast(this.targets);
+
+      if (this.isInteracting()) {
         const distance = Math.min(
-          this.data.maxDistance,
-          Math.max(this.data.minDistance, this.currentDistance - this.currentDistanceMod)
+          this.data.far,
+          Math.max(this.data.near, this.currentDistance - this.currentDistanceMod)
         );
-        this.direction.multiplyScalar(distance);
-        this.data.cursor.object3D.position.addVectors(this.origin, this.direction);
+        this.data.cursor.object3D.position.copy(this.raycaster.ray.origin);
+        this.data.cursor.object3D.position.addScaledVector(this.raycaster.ray.direction, distance);
       } else {
         this.currentDistanceMod = 0;
         this.updateDistanceAndTargetType();
@@ -96,7 +156,10 @@ AFRAME.registerComponent("cursor-controller", {
       }
 
       if (this.data.drawLine) {
-        this.el.setAttribute("line", { start: this.origin.clone(), end: this.data.cursor.object3D.position.clone() });
+        this.el.setAttribute("line", {
+          start: this.raycaster.ray.origin.clone(),
+          end: this.data.cursor.object3D.position.clone()
+        });
       }
 
       // The cursor will always be oriented towards the player about its Y axis, so objects held by the cursor will rotate towards the player.
@@ -106,26 +169,15 @@ AFRAME.registerComponent("cursor-controller", {
     };
   })(),
 
-  setRaycasterWithMousePos: function() {
-    const camera = this.data.camera.components.camera.camera;
-    const raycaster = this.el.components.raycaster.raycaster;
-    raycaster.setFromCamera(this.mousePos, camera);
-    this.origin.copy(raycaster.ray.origin);
-    this.direction.copy(raycaster.ray.direction);
-    this.updateRay();
-  },
-
   updateDistanceAndTargetType: function() {
-    let intersection = null;
-    const intersections = this.el.components.raycaster.intersections;
-    if (intersections.length > 0 && intersections[0].distance <= this.data.maxDistance) {
-      intersection = intersections[0];
+    const intersection = this.intersection;
+    if (intersection && intersection.distance <= this.data.far) {
       this.data.cursor.object3D.position.copy(intersection.point);
-      this.currentDistance = intersections[0].distance;
+      this.currentDistance = intersection.distance;
     } else {
-      this.currentDistance = this.data.maxDistance;
-      this.direction.multiplyScalar(this.currentDistance);
-      this.data.cursor.object3D.position.addVectors(this.origin, this.direction);
+      this.currentDistance = this.data.far;
+      this.data.cursor.object3D.position.copy(this.raycaster.ray.origin);
+      this.data.cursor.object3D.position.addScaledVector(this.raycaster.ray.direction, this.currentDistance);
     }
 
     if (!intersection) {
@@ -147,12 +199,15 @@ AFRAME.registerComponent("cursor-controller", {
   },
 
   forceCursorUpdate: function() {
-    this.setRaycasterWithMousePos();
-    this.el.components.raycaster.checkIntersections();
+    this.performRaycast(this.targets);
     this.updateDistanceAndTargetType();
     this.data.cursor.components["static-body"].syncToPhysics();
   },
 
+  isInteracting: function() {
+    return this.data.cursor.components["super-hands"].state.has("grab-start");
+  },
+
   startInteraction: function() {
     if (this._isTargetOfType(TARGET_TYPE_INTERACTABLE_OR_UI)) {
       this.data.cursor.emit("cursor-grab", {});
@@ -161,22 +216,24 @@ AFRAME.registerComponent("cursor-controller", {
     return false;
   },
 
-  moveCursor: function(x, y) {
-    this.mousePos.set(x, y);
-  },
-
   endInteraction: function() {
     this.data.cursor.emit("cursor-release", {});
   },
 
+  moveCursor: function(x, y) {
+    this.mousePos.set(x, y);
+  },
+
   changeDistanceMod: function(delta) {
-    const { minDistance, maxDistance } = this.data;
+    const { near, far } = this.data;
     const targetDistanceMod = this.currentDistanceMod + delta;
     const moddedDistance = this.currentDistance - targetDistanceMod;
-    if (moddedDistance > maxDistance || moddedDistance < minDistance) {
-      return;
+    if (moddedDistance > far || moddedDistance < near) {
+      return false;
     }
+
     this.currentDistanceMod = targetDistanceMod;
+    return true;
   },
 
   _handleCursorLoaded: function() {
@@ -185,6 +242,8 @@ AFRAME.registerComponent("cursor-controller", {
   },
 
   remove: function() {
+    this.emitIntersectionEvents(this.intersection, null);
+    this.intersection = null;
     this.data.cursor.removeEventListener("loaded", this._handleCursorLoaded);
   }
 });
diff --git a/src/components/gltf-bundle.js b/src/components/gltf-bundle.js
index 72163ec542922ea21c5c7612b54b193f5c256a97..7ad7840822faac29b7e70f148fe2ff12ae3d6819 100644
--- a/src/components/gltf-bundle.js
+++ b/src/components/gltf-bundle.js
@@ -28,7 +28,7 @@ AFRAME.registerComponent("gltf-bundle", {
 
       const src = new URL(asset.src, this.baseURL).href;
       const gltfEl = document.createElement("a-entity");
-      gltfEl.setAttribute("gltf-model-plus", { src, inflate: true });
+      gltfEl.setAttribute("gltf-model-plus", { src, useCache: false, inflate: true });
       loaded.push(new Promise(resolve => gltfEl.addEventListener("model-loaded", resolve)));
       this.el.appendChild(gltfEl);
     }
diff --git a/src/components/gltf-model-plus.js b/src/components/gltf-model-plus.js
index e898bf82af0b429a7531a2083b2d6def5e9f306d..625fc4c3536a1660b4a908d4d8d6e47e36377f44 100644
--- a/src/components/gltf-model-plus.js
+++ b/src/components/gltf-model-plus.js
@@ -2,7 +2,7 @@ import SketchfabZipWorker from "../workers/sketchfab-zip.worker.js";
 import cubeMapPosX from "../assets/images/cubemap/posx.jpg";
 import cubeMapNegX from "../assets/images/cubemap/negx.jpg";
 import cubeMapPosY from "../assets/images/cubemap/posy.jpg";
-import cubeMapNegY from "../assets/images/cubemap/negx.jpg";
+import cubeMapNegY from "../assets/images/cubemap/negy.jpg";
 import cubeMapPosZ from "../assets/images/cubemap/posz.jpg";
 import cubeMapNegZ from "../assets/images/cubemap/negz.jpg";
 
@@ -279,6 +279,7 @@ AFRAME.registerComponent("gltf-model-plus", {
   schema: {
     src: { type: "string" },
     contentType: { type: "string" },
+    useCache: { default: true },
     inflate: { default: false }
   },
 
@@ -300,6 +301,17 @@ AFRAME.registerComponent("gltf-model-plus", {
     });
   },
 
+  async loadModel(src, contentType, technique, useCache) {
+    if (useCache) {
+      if (!GLTFCache[src]) {
+        GLTFCache[src] = await loadGLTF(src, contentType, technique);
+      }
+      return cloneGltf(GLTFCache[src]);
+    } else {
+      return await loadGLTF(src, contentType, technique);
+    }
+  },
+
   async applySrc(src, contentType) {
     try {
       // If the src attribute is a selector, get the url from the asset item.
@@ -319,11 +331,7 @@ AFRAME.registerComponent("gltf-model-plus", {
         return;
       }
 
-      if (!GLTFCache[src]) {
-        GLTFCache[src] = loadGLTF(src, contentType, this.preferredTechnique);
-      }
-
-      const model = cloneGltf(await GLTFCache[src]);
+      const model = await this.loadModel(src, contentType, this.preferredTechnique, this.data.useCache);
 
       // If we started loading something else already
       // TODO: there should be a way to cancel loading instead
diff --git a/src/components/grabbable-toggle.js b/src/components/grabbable-toggle.js
new file mode 100644
index 0000000000000000000000000000000000000000..5a11d5698d5ba53cbafeab63286bf142b1699bfb
--- /dev/null
+++ b/src/components/grabbable-toggle.js
@@ -0,0 +1,185 @@
+/* global AFRAME, THREE */
+const inherit = AFRAME.utils.extendDeep;
+const physicsCore = require("super-hands/reaction_components/prototypes/physics-grab-proto.js");
+const buttonsCore = require("super-hands/reaction_components/prototypes/buttons-proto.js");
+// new object with all core modules
+const base = inherit({}, physicsCore, buttonsCore);
+AFRAME.registerComponent(
+  "grabbable-toggle",
+  inherit(base, {
+    schema: {
+      maxGrabbers: { type: "int", default: NaN },
+      invert: { default: false },
+      suppressY: { default: false },
+      primaryReleaseEvents: { default: ["primary_hand_release"] },
+      secondaryReleaseEvents: { default: ["secondary_hand_release"] }
+    },
+    init: function() {
+      this.GRABBED_STATE = "grabbed";
+      this.GRAB_EVENT = "grab-start";
+      this.UNGRAB_EVENT = "grab-end";
+      this.grabbed = false;
+      this.grabbers = [];
+      this.constraints = new Map();
+      this.deltaPositionIsValid = false;
+      this.grabDistance = undefined;
+      this.grabDirection = { x: 0, y: 0, z: -1 };
+      this.grabOffset = { x: 0, y: 0, z: 0 };
+      // persistent object speeds up repeat setAttribute calls
+      this.destPosition = { x: 0, y: 0, z: 0 };
+      this.deltaPosition = new THREE.Vector3();
+      this.targetPosition = new THREE.Vector3();
+      this.physicsInit();
+
+      this.el.addEventListener(this.GRAB_EVENT, e => this.start(e));
+      this.el.addEventListener(this.UNGRAB_EVENT, e => this.end(e));
+      this.el.addEventListener("mouseout", e => this.lostGrabber(e));
+
+      this.toggle = false;
+      this.lastGrabber = null;
+    },
+    update: function() {
+      this.physicsUpdate();
+      this.xFactor = this.data.invert ? -1 : 1;
+      this.zFactor = this.data.invert ? -1 : 1;
+      this.yFactor = (this.data.invert ? -1 : 1) * !this.data.suppressY;
+    },
+    tick: (function() {
+      const q = new THREE.Quaternion();
+      const v = new THREE.Vector3();
+
+      return function() {
+        let entityPosition;
+        if (this.grabber) {
+          // reflect on z-axis to point in same direction as the laser
+          this.targetPosition.copy(this.grabDirection);
+          this.targetPosition
+            .applyQuaternion(this.grabber.object3D.getWorldQuaternion(q))
+            .setLength(this.grabDistance)
+            .add(this.grabber.object3D.getWorldPosition(v))
+            .add(this.grabOffset);
+          if (this.deltaPositionIsValid) {
+            // relative position changes work better with nested entities
+            this.deltaPosition.sub(this.targetPosition);
+            entityPosition = this.el.getAttribute("position");
+            this.destPosition.x = entityPosition.x - this.deltaPosition.x * this.xFactor;
+            this.destPosition.y = entityPosition.y - this.deltaPosition.y * this.yFactor;
+            this.destPosition.z = entityPosition.z - this.deltaPosition.z * this.zFactor;
+            this.el.setAttribute("position", this.destPosition);
+          } else {
+            this.deltaPositionIsValid = true;
+          }
+          this.deltaPosition.copy(this.targetPosition);
+        }
+      };
+    })(),
+    remove: function() {
+      this.el.removeEventListener(this.GRAB_EVENT, this.start);
+      this.el.removeEventListener(this.UNGRAB_EVENT, this.end);
+      this.physicsRemove();
+    },
+    start: function(evt) {
+      if (evt.defaultPrevented || !this.startButtonOk(evt)) {
+        return;
+      }
+      // room for more grabbers?
+      let grabAvailable = !Number.isFinite(this.data.maxGrabbers) || this.grabbers.length < this.data.maxGrabbers;
+      if (Number.isFinite(this.data.maxGrabbers) && !grabAvailable && this.grabbed) {
+        this.grabbers[0].components["super-hands"].onGrabEndButton();
+        grabAvailable = true;
+      }
+      if (this.grabbers.indexOf(evt.detail.hand) === -1 && grabAvailable) {
+        if (!evt.detail.hand.object3D) {
+          console.warn("grabbable entities must have an object3D");
+          return;
+        }
+        this.grabbers.push(evt.detail.hand);
+        // initiate physics if available, otherwise manual
+        if (!this.physicsStart(evt) && !this.grabber) {
+          this.grabber = evt.detail.hand;
+          this.resetGrabber();
+        }
+        // notify super-hands that the gesture was accepted
+        if (evt.preventDefault) {
+          evt.preventDefault();
+        }
+        this.grabbed = true;
+        this.el.addState(this.GRABBED_STATE);
+      }
+    },
+    end: function(evt) {
+      const handIndex = this.grabbers.indexOf(evt.detail.hand);
+      if (evt.defaultPrevented || !this.endButtonOk(evt)) {
+        return;
+      }
+
+      const type = evt.detail && evt.detail.buttonEvent ? evt.detail.buttonEvent.type : null;
+
+      if (this.toggle && this.lastGrabber !== this.grabbers[0]) {
+        this.toggle = false;
+        this.lastGrabber = null;
+      }
+
+      if (handIndex !== -1) {
+        this.grabber = this.grabbers[0];
+      }
+
+      if ((this.isPrimaryRelease(type) && !this.toggle) || this.isSecondaryRelease(type)) {
+        this.toggle = true;
+        this.lastGrabber = this.grabbers[0];
+        return;
+      } else if (this.toggle && this.isPrimaryRelease(type)) {
+        this.toggle = false;
+        this.lastGrabber = null;
+      }
+
+      if (handIndex !== -1) {
+        this.grabbers.splice(handIndex, 1);
+        this.grabber = this.grabbers[0];
+      }
+
+      this.physicsEnd(evt);
+      if (!this.resetGrabber()) {
+        this.grabbed = false;
+        this.el.removeState(this.GRABBED_STATE);
+      }
+      if (evt.preventDefault) {
+        evt.preventDefault();
+      }
+    },
+    resetGrabber: (() => {
+      const objPos = new THREE.Vector3();
+      const grabPos = new THREE.Vector3();
+      return function() {
+        if (!this.grabber) {
+          return false;
+        }
+        const raycaster = this.grabber.getAttribute("raycaster");
+        this.deltaPositionIsValid = false;
+        this.grabDistance = this.el.object3D
+          .getWorldPosition(objPos)
+          .distanceTo(this.grabber.object3D.getWorldPosition(grabPos));
+        if (raycaster) {
+          this.grabDirection = raycaster.direction;
+          this.grabOffset = raycaster.origin;
+        }
+        return true;
+      };
+    })(),
+    lostGrabber: function(evt) {
+      const i = this.grabbers.indexOf(evt.relatedTarget);
+      // if a queued, non-physics grabber leaves the collision zone, forget it
+      if (i !== -1 && evt.relatedTarget !== this.grabber && !this.physicsIsConstrained(evt.relatedTarget)) {
+        this.grabbers.splice(i, 1);
+      }
+    },
+
+    isPrimaryRelease(type) {
+      return this.data.primaryReleaseEvents.indexOf(type) !== -1;
+    },
+
+    isSecondaryRelease(type) {
+      return this.data.secondaryReleaseEvents.indexOf(type) !== -1;
+    }
+  })
+);
diff --git a/src/components/in-world-hud.js b/src/components/in-world-hud.js
index a4fe8c9655ecffe3161a86b09fa3b6f6cdbdb19b..1bee398de9521f9a12e7fd0eb12b2af035a89041 100644
--- a/src/components/in-world-hud.js
+++ b/src/components/in-world-hud.js
@@ -11,23 +11,23 @@ AFRAME.registerComponent("in-world-hud", {
   init() {
     this.mic = this.el.querySelector(".mic");
     this.freeze = this.el.querySelector(".freeze");
-    this.bubble = this.el.querySelector(".bubble");
+    this.pen = this.el.querySelector(".pen");
     this.background = this.el.querySelector(".bg");
     const renderOrder = window.APP.RENDER_ORDER;
     this.mic.object3DMap.mesh.renderOrder = renderOrder.HUD_ICONS;
     this.freeze.object3DMap.mesh.renderOrder = renderOrder.HUD_ICONS;
-    this.bubble.object3DMap.mesh.renderOrder = renderOrder.HUD_ICONS;
+    this.pen.object3DMap.mesh.renderOrder = renderOrder.HUD_ICONS;
     this.background.object3DMap.mesh.renderORder = renderOrder.HUD_BACKGROUND;
 
     this.updateButtonStates = () => {
       this.mic.setAttribute("icon-button", "active", this.el.sceneEl.is("muted"));
       this.freeze.setAttribute("icon-button", "active", this.el.sceneEl.is("frozen"));
-      this.bubble.setAttribute("icon-button", "active", this.el.sceneEl.is("spacebubble"));
+      this.pen.setAttribute("icon-button", "active", this.el.sceneEl.is("pen"));
     };
     this.updateButtonStates();
 
     this.onStateChange = evt => {
-      if (!(evt.detail === "muted" || evt.detail === "frozen" || evt.detail === "spacebubble")) return;
+      if (!(evt.detail === "muted" || evt.detail === "frozen" || evt.detail === "pen")) return;
       this.updateButtonStates();
     };
 
@@ -39,8 +39,8 @@ AFRAME.registerComponent("in-world-hud", {
       this.el.emit("action_freeze");
     };
 
-    this.onBubbleClick = () => {
-      this.el.emit("action_space_bubble");
+    this.onPenClick = () => {
+      this.el.emit("spawn_pen");
     };
   },
 
@@ -50,7 +50,7 @@ AFRAME.registerComponent("in-world-hud", {
 
     this.mic.addEventListener("click", this.onMicClick);
     this.freeze.addEventListener("click", this.onFreezeClick);
-    this.bubble.addEventListener("click", this.onBubbleClick);
+    this.pen.addEventListener("mousedown", this.onPenClick);
   },
 
   pause() {
@@ -59,6 +59,6 @@ AFRAME.registerComponent("in-world-hud", {
 
     this.mic.removeEventListener("click", this.onMicClick);
     this.freeze.removeEventListener("click", this.onFreezeClick);
-    this.bubble.removeEventListener("click", this.onBubbleClick);
+    this.pen.removeEventListener("mousedown", this.onPenClick);
   }
 });
diff --git a/src/components/input-configurator.js b/src/components/input-configurator.js
index df36b38d8c4bb81a2f8fd98824cbd3ecf4ef7f15..356213fbf319e5037bcb21166aa11e794ccc5f94 100644
--- a/src/components/input-configurator.js
+++ b/src/components/input-configurator.js
@@ -21,7 +21,6 @@ AFRAME.registerComponent("input-configurator", {
     this.isMobile = AFRAME.utils.device.isMobile();
     this.eventHandlers = [];
     this.controllerQueue = [];
-    this.hasPointingDevice = false;
     this.cursor = this.data.cursorController.components["cursor-controller"];
     this.gazeTeleporter = this.data.gazeTeleporter.components["teleport-controls"];
     this.cameraController = this.data.camera.components["pitch-yaw-rotator"];
@@ -89,12 +88,10 @@ AFRAME.registerComponent("input-configurator", {
     this.actionEventHandler = new ActionEventHandler(this.el.sceneEl, this.cursor);
     this.eventHandlers.push(this.actionEventHandler);
 
-    this.cursor.el.setAttribute("cursor-controller", "useMousePos", !this.inVR);
-
     if (this.inVR) {
       this.cameraController.pause();
       this.cursorRequiresManagement = true;
-      this.cursor.el.setAttribute("cursor-controller", "minDistance", 0);
+      this.cursor.el.setAttribute("cursor-controller", "near", 0);
       if (this.isMobile) {
         this.eventHandlers.push(new GearVRMouseEventsHandler(this.cursor, this.gazeTeleporter));
       } else {
@@ -108,7 +105,7 @@ AFRAME.registerComponent("input-configurator", {
         this.addLookOnMobile();
       } else {
         this.eventHandlers.push(new MouseEventsHandler(this.cursor, this.cameraController));
-        this.cursor.el.setAttribute("cursor-controller", "minDistance", 0.3);
+        this.cursor.el.setAttribute("cursor-controller", "near", 0.3);
       }
     }
   },
@@ -145,25 +142,30 @@ AFRAME.registerComponent("input-configurator", {
   },
 
   updateController: function() {
-    this.hasPointingDevice = this.controllerQueue.length > 0 && this.inVR;
-    this.cursor.el.setAttribute("cursor-controller", "drawLine", this.hasPointingDevice);
-
     this.cursor.setCursorVisibility(true);
+    const controllerData = this.controllerQueue.length ? this.controllerQueue[0] : null;
 
-    if (this.hasPointingDevice) {
-      const controllerData = this.controllerQueue[0];
-      const hand = controllerData.handedness;
+    if (controllerData) {
       this.controller = controllerData.controller;
-      this.cursor.el.setAttribute("cursor-controller", {
-        rayObject: hand === "left" ? this.data.leftControllerRayObject : this.data.rightControllerRayObject
-      });
+      this.actionEventHandler.setHandThatAlsoDrivesCursor(this.controller);
     } else {
       this.controller = null;
-      this.cursor.el.setAttribute("cursor-controller", { rayObject: this.data.gazeCursorRayObject });
+      this.actionEventHandler.setHandThatAlsoDrivesCursor(null);
     }
 
-    if (this.actionEventHandler) {
-      this.actionEventHandler.setHandThatAlsoDrivesCursor(this.controller);
+    let rayObject;
+    let drawLine;
+    if (controllerData && this.inVR) {
+      rayObject =
+        controllerData.handedness === "left" ? this.data.leftControllerRayObject : this.data.rightControllerRayObject;
+      drawLine = true;
+    } else if (this.inVR) {
+      rayObject = this.data.gazeCursorRayObject;
+      drawLine = false;
+    } else {
+      rayObject = null;
+      drawLine = false;
     }
+    this.cursor.el.setAttribute("cursor-controller", { rayObject, drawLine });
   }
 });
diff --git a/src/components/media-loader.js b/src/components/media-loader.js
index 802d582c5a1b1dfac3961420dcf8bfde6d0619b6..da20ee178906bd68a9aa7a23496e208e5676e37f 100644
--- a/src/components/media-loader.js
+++ b/src/components/media-loader.js
@@ -20,6 +20,8 @@ AFRAME.registerComponent("media-loader", {
     this.onError = this.onError.bind(this);
     this.showLoader = this.showLoader.bind(this);
     this.clearLoadingTimeout = this.clearLoadingTimeout.bind(this);
+    this.shapeAdded = false;
+    this.hasBakedShapes = false;
   },
 
   setShapeAndScale(resize) {
@@ -27,9 +29,10 @@ AFRAME.registerComponent("media-loader", {
     const box = getBox(this.el, mesh);
     const scaleCoefficient = resize ? getScaleCoefficient(0.5, box) : 1;
     this.el.object3DMap.mesh.scale.multiplyScalar(scaleCoefficient);
-    if (this.el.body && this.el.body.shapes.length > 1) {
+    if (this.el.body && this.shapeAdded && this.el.body.shapes.length > 1) {
       this.el.removeAttribute("shape");
-    } else {
+      this.shapeAdded = false;
+    } else if (!this.hasBakedShapes) {
       const center = new THREE.Vector3();
       const { min, max } = box;
       const halfExtents = {
@@ -43,6 +46,7 @@ AFRAME.registerComponent("media-loader", {
         shape: "box",
         halfExtents: halfExtents
       });
+      this.shapeAdded = true;
     }
   },
 
@@ -72,6 +76,7 @@ AFRAME.registerComponent("media-loader", {
       this.loadingClip.play();
     }
     this.el.setObject3D("mesh", mesh);
+    this.hasBakedShapes = !!(this.el.body && this.el.body.shapes.length > 0);
     this.setShapeAndScale(true);
     delete this.showLoaderTimeout;
   },
@@ -146,6 +151,7 @@ AFRAME.registerComponent("media-loader", {
           "model-loaded",
           () => {
             this.clearLoadingTimeout();
+            this.hasBakedShapes = !!(this.el.body && this.el.body.shapes.length > (this.shapeAdded ? 1 : 0));
             this.setShapeAndScale(this.data.resize);
           },
           { once: true }
diff --git a/src/components/offset-relative-to.js b/src/components/offset-relative-to.js
index e00acd4e57a1842e5b8116c5448a3c2088443eab..877cfcf8a118a13eb94200925d1ac53053d9faa1 100644
--- a/src/components/offset-relative-to.js
+++ b/src/components/offset-relative-to.js
@@ -13,6 +13,9 @@ AFRAME.registerComponent("offset-relative-to", {
     on: {
       type: "string"
     },
+    orientation: {
+      default: 1 // see doc/image_orientations.gif
+    },
     selfDestruct: {
       default: false
     }
@@ -27,6 +30,9 @@ AFRAME.registerComponent("offset-relative-to", {
   },
 
   updateOffset: (function() {
+    const y = new THREE.Vector3(0, 1, 0);
+    const z = new THREE.Vector3(0, 0, -1);
+    const QUARTER_CIRCLE = Math.PI / 2;
     const offsetVector = new THREE.Vector3();
     return function() {
       const obj = this.el.object3D;
@@ -40,6 +46,38 @@ AFRAME.registerComponent("offset-relative-to", {
       this.el.body && this.el.body.position.copy(obj.position);
       target.getWorldQuaternion(obj.quaternion);
       this.el.body && this.el.body.quaternion.copy(obj.quaternion);
+
+      // See doc/image_orientations.gif
+      switch (this.data.orientation) {
+        case 8:
+          obj.rotateOnAxis(z, 3 * QUARTER_CIRCLE);
+          break;
+        case 7:
+          obj.rotateOnAxis(z, 3 * QUARTER_CIRCLE);
+          obj.rotateOnAxis(y, 2 * QUARTER_CIRCLE);
+          break;
+        case 6:
+          obj.rotateOnAxis(z, QUARTER_CIRCLE);
+          break;
+        case 5:
+          obj.rotateOnAxis(z, QUARTER_CIRCLE);
+          obj.rotateOnAxis(y, 2 * QUARTER_CIRCLE);
+          break;
+        case 4:
+          obj.rotateOnAxis(z, 2 * QUARTER_CIRCLE);
+          obj.rotateOnAxis(y, 2 * QUARTER_CIRCLE);
+          break;
+        case 3:
+          obj.rotateOnAxis(z, 2 * QUARTER_CIRCLE);
+          break;
+        case 2:
+          obj.rotateOnAxis(y, 2 * QUARTER_CIRCLE);
+          break;
+        case 1:
+        default:
+          break;
+      }
+
       if (this.data.selfDestruct) {
         if (this.data.on) {
           this.el.sceneEl.removeEventListener(this.data.on, this.updateOffset);
diff --git a/src/components/sticky-object.js b/src/components/sticky-object.js
index d84c82313100622864b9457161b71b842edf6309..61d04814a95b2e30cc79743a039dec1c0a6d5235 100644
--- a/src/components/sticky-object.js
+++ b/src/components/sticky-object.js
@@ -47,17 +47,21 @@ AFRAME.registerComponent("sticky-object", {
   },
 
   _onRelease() {
+    // Happens if the object is still being held by another hand
+    if (this.el.is("grabbed")) return;
+
     if (
-      !this.el.is("grabbed") &&
       this.data.autoLockOnRelease &&
       this.el.body.velocity.lengthSquared() < this.data.autoLockSpeedLimit * this.data.autoLockSpeedLimit
     ) {
       this.setLocked(true);
     }
+    this.el.body.collisionResponse = true;
   },
 
   _onGrab() {
     this.setLocked(false);
+    this.el.body.collisionResponse = false;
   },
 
   remove() {
diff --git a/src/components/super-networked-interactable.js b/src/components/super-networked-interactable.js
index 844dc1df1706f2a51f55afd531e41898724fcb77..d2e04e23911cb499c1ce2c9eb7a1a62ab1ae2e53 100644
--- a/src/components/super-networked-interactable.js
+++ b/src/components/super-networked-interactable.js
@@ -6,13 +6,18 @@
 AFRAME.registerComponent("super-networked-interactable", {
   schema: {
     hapticsMassVelocityFactor: { default: 0.1 },
-    counter: { type: "selector" }
+    counter: { type: "selector" },
+    scrollScaleDelta: { default: 0.1 },
+    minScale: { default: 0.1 },
+    maxScale: { default: 100 }
   },
 
   init: function() {
     this.system = this.el.sceneEl.systems.physics;
     this.counter = this.data.counter.components["networked-counter"];
     this.hand = null;
+    this.currentScale = new THREE.Vector3();
+    this.currentScale.copy(this.el.getAttribute("scale"));
 
     NAF.utils.getNetworkedEntity(this.el).then(networkedEl => {
       this.networkedEl = networkedEl;
@@ -23,31 +28,29 @@ AFRAME.registerComponent("super-networked-interactable", {
       }
     });
 
+    this._stateAdded = this._stateAdded.bind(this);
     this._onGrabStart = this._onGrabStart.bind(this);
+    this._onGrabEnd = this._onGrabEnd.bind(this);
     this._onOwnershipLost = this._onOwnershipLost.bind(this);
     this.el.addEventListener("grab-start", this._onGrabStart);
+    this.el.addEventListener("grab-end", this._onGrabEnd);
     this.el.addEventListener("ownership-lost", this._onOwnershipLost);
+    this.el.addEventListener("stateadded", this._stateAdded);
     this.system.addComponent(this);
   },
 
   remove: function() {
     this.counter.deregister(this.el);
     this.el.removeEventListener("grab-start", this._onGrabStart);
+    this.el.removeEventListener("grab-end", this._onGrabEnd);
     this.el.removeEventListener("ownership-lost", this._onOwnershipLost);
+    this.el.removeEventListener("stateadded", this._stateAdded);
     this.system.removeComponent(this);
   },
 
-  afterStep: function() {
-    if (this.el.is("grabbed") && this.hand && this.hand.components.hasOwnProperty("haptic-feedback")) {
-      const hapticFeedback = this.hand.components["haptic-feedback"];
-      let velocity = this.el.body.velocity.lengthSquared() * this.el.body.mass * this.data.hapticsMassVelocityFactor;
-      velocity = Math.min(1, velocity);
-      hapticFeedback.pulse(velocity);
-    }
-  },
-
   _onGrabStart: function(e) {
     this.hand = e.detail.hand;
+    this.hand.emit("haptic_pulse", { intensity: "high" });
     if (this.networkedEl && !NAF.utils.isMine(this.networkedEl)) {
       if (NAF.utils.takeOwnership(this.networkedEl)) {
         this.el.setAttribute("body", { type: "dynamic" });
@@ -57,6 +60,11 @@ AFRAME.registerComponent("super-networked-interactable", {
         this.hand = null;
       }
     }
+    this.currentScale.copy(this.el.getAttribute("scale"));
+  },
+
+  _onGrabEnd: function(e) {
+    if (e.detail.hand) e.detail.hand.emit("haptic_pulse", { intensity: "high" });
   },
 
   _onOwnershipLost: function() {
@@ -64,5 +72,26 @@ AFRAME.registerComponent("super-networked-interactable", {
     this.el.emit("grab-end", { hand: this.hand });
     this.hand = null;
     this.counter.deregister(this.el);
+  },
+
+  _changeScale: function(delta) {
+    if (this.el.is("grabbed") && this.el.components.hasOwnProperty("stretchable")) {
+      this.currentScale.addScalar(delta).clampScalar(this.data.minScale, this.data.maxScale);
+      this.el.setAttribute("scale", this.currentScale);
+      this.el.components["stretchable"].stretchBody(this.el, this.currentScale);
+    }
+  },
+
+  _stateAdded(evt) {
+    switch (evt.detail) {
+      case "scaleUp":
+        this._changeScale(-this.data.scrollScaleDelta);
+        break;
+      case "scaleDown":
+        this._changeScale(this.data.scrollScaleDelta);
+        break;
+      default:
+        break;
+    }
   }
 });
diff --git a/src/components/super-spawner.js b/src/components/super-spawner.js
index f760a29e25cc1fb680b55a0b504a42202948f0da..425b1c834da9b7a75b54cad155224c33ec8edf7a 100644
--- a/src/components/super-spawner.js
+++ b/src/components/super-spawner.js
@@ -4,7 +4,7 @@ import { ObjectContentOrigins } from "../object-types";
 
 let nextGrabId = 0;
 /**
- * Spawns networked objects when grabbed.
+ * Spawns networked objects when grabbed or when a specified event is fired.
  * @namespace network
  * @component super-spawner
  */
@@ -13,7 +13,12 @@ AFRAME.registerComponent("super-spawner", {
     /**
      * Source of the media asset the spawner will spawn when grabbed. This can be a gltf, video, or image, or a url that the reticiulm media API can resolve to a gltf, video, or image.
      */
-    src: { default: "https://asset-bundles-prod.reticulum.io/interactables/Ducky/DuckyMesh-438ff8e022.gltf" },
+    src: { default: "" },
+
+    /**
+     * The template to use for this object
+     */
+    template: { default: "" },
 
     /**
      * Spawn the object at a custom position, rather than at the center of the spanwer.
@@ -27,11 +32,17 @@ AFRAME.registerComponent("super-spawner", {
     useCustomSpawnRotation: { default: false },
     spawnRotation: { type: "vec4" },
 
+    /**
+     * Spawn the object with a custom scale, rather than copying that of the spawner.
+     */
+    useCustomSpawnScale: { default: false },
+    spawnScale: { type: "vec3" },
+
     /**
      * The events to emit for programmatically grabbing and releasing objects
      */
-    grabEvents: { default: ["cursor-grab", "hand_grab"] },
-    releaseEvents: { default: ["cursor-release", "hand_release"] },
+    grabEvents: { default: ["cursor-grab", "primary_hand_grab"] },
+    releaseEvents: { default: ["cursor-release", "primary_hand_release"] },
 
     /**
      * The spawner will become invisible and ungrabbable for this ammount of time after being grabbed. This can prevent rapidly spawning objects.
@@ -41,7 +52,22 @@ AFRAME.registerComponent("super-spawner", {
     /**
      * Center the spawned object on the hand that grabbed it after it finishes loading. By default the object will be grabbed relative to where the spawner was grabbed
      */
-    centerSpawnedObject: { default: false }
+    centerSpawnedObject: { default: false },
+
+    /**
+     * Optional event to listen for to spawn an object on the preferred superHand
+     */
+    spawnEvent: { type: "string" },
+
+    /**
+     * The superHand to use if an object is spawned via spawnEvent
+     */
+    superHand: { type: "selector" },
+
+    /**
+     * The cursor superHand to use if an object is spawned via spawnEvent
+     */
+    cursorSuperHand: { type: "selector" }
   },
 
   init() {
@@ -49,16 +75,26 @@ AFRAME.registerComponent("super-spawner", {
     this.cooldownTimeout = null;
     this.onGrabStart = this.onGrabStart.bind(this);
     this.onGrabEnd = this.onGrabEnd.bind(this);
+
+    this.onSpawnEvent = this.onSpawnEvent.bind(this);
+
+    this.sceneEl = document.querySelector("a-scene");
   },
 
   play() {
     this.el.addEventListener("grab-start", this.onGrabStart);
     this.el.addEventListener("grab-end", this.onGrabEnd);
+    if (this.data.spawnEvent) {
+      this.sceneEl.addEventListener(this.data.spawnEvent, this.onSpawnEvent);
+    }
   },
 
   pause() {
     this.el.removeEventListener("grab-start", this.onGrabStart);
     this.el.removeEventListener("grab-end", this.onGrabEnd);
+    if (this.data.spawnEvent) {
+      this.sceneEl.removeEventListener(this.data.spawnEvent, this.onSpawnEvent);
+    }
 
     if (this.cooldownTimeout) {
       clearTimeout(this.cooldownTimeout);
@@ -72,6 +108,36 @@ AFRAME.registerComponent("super-spawner", {
     this.heldEntities.clear();
   },
 
+  async onSpawnEvent() {
+    const controllerCount = this.el.sceneEl.components["input-configurator"].controllerQueue.length;
+    const using6DOF = controllerCount > 1 && this.el.sceneEl.is("vr-mode");
+    const hand = using6DOF ? this.data.superHand : this.data.cursorSuperHand;
+
+    if (this.cooldownTimeout || !hand) {
+      return;
+    }
+
+    const entity = addMedia(this.data.src, this.data.template, ObjectContentOrigins.SPAWNER).entity;
+
+    hand.object3D.getWorldPosition(entity.object3D.position);
+    hand.object3D.getWorldQuaternion(entity.object3D.quaternion);
+    if (this.data.useCustomSpawnScale) {
+      entity.object3D.scale.copy(this.data.spawnScale);
+    }
+
+    this.activateCooldown();
+
+    await waitForEvent("body-loaded", entity);
+
+    hand.object3D.getWorldPosition(entity.object3D.position);
+
+    if (!using6DOF) {
+      for (let i = 0; i < this.data.grabEvents.length; i++) {
+        hand.emit(this.data.grabEvents[i], { targetEntity: entity });
+      }
+    }
+  },
+
   async onGrabStart(e) {
     if (this.cooldownTimeout) {
       return;
@@ -84,13 +150,15 @@ AFRAME.registerComponent("super-spawner", {
     const thisGrabId = nextGrabId++;
     this.heldEntities.set(hand, thisGrabId);
 
-    const entity = addMedia(this.data.src, ObjectContentOrigins.SPAWNER);
+    const entity = addMedia(this.data.src, this.data.template, ObjectContentOrigins.SPAWNER).entity;
+
     entity.object3D.position.copy(
       this.data.useCustomSpawnPosition ? this.data.spawnPosition : this.el.object3D.position
     );
     entity.object3D.rotation.copy(
       this.data.useCustomSpawnRotation ? this.data.spawnRotation : this.el.object3D.rotation
     );
+    entity.object3D.scale.copy(this.data.useCustomSpawnScale ? this.data.spawnScale : this.el.object3D.scale);
 
     this.activateCooldown();
 
diff --git a/src/components/tools/drawing-manager.js b/src/components/tools/drawing-manager.js
new file mode 100644
index 0000000000000000000000000000000000000000..08d1ef5e0e8b1b0cfacee02905f1ad7ba6502ca7
--- /dev/null
+++ b/src/components/tools/drawing-manager.js
@@ -0,0 +1,51 @@
+/**
+ * Drawing Manager
+ * Manages what networked-drawings are available to pen components
+ * @namespace drawing
+ * @component drawing-manager
+ */
+AFRAME.registerComponent("drawing-manager", {
+  init() {
+    this._onComponentInitialized = this._onComponentInitialized.bind(this);
+
+    this.drawingToPen = new Map();
+  },
+
+  remove() {
+    if (this.drawingEl) {
+      this.drawingEl.removeEventListener("componentinitialized", this._onComponentInitialized);
+    }
+  },
+
+  _onComponentInitialized(e) {
+    if (e.detail.name == "networked-drawing") {
+      this.drawing = this.drawingEl.components["networked-drawing"];
+    }
+  },
+
+  createDrawing() {
+    if (!this.drawingEl) {
+      this.drawingEl = document.createElement("a-entity");
+      this.drawingEl.setAttribute("networked", "template: #interactable-drawing");
+      this.el.sceneEl.appendChild(this.drawingEl);
+
+      this.drawingEl.addEventListener("componentinitialized", this._onComponentInitialized);
+    }
+  },
+
+  getDrawing(pen) {
+    //TODO: future handling of multiple drawings
+    if (this.drawing && (!this.drawingToPen.has(this.drawing) || this.drawingToPen.get(this.drawing) === pen)) {
+      this.drawingToPen.set(this.drawing, pen);
+      return this.drawing;
+    }
+
+    return null;
+  },
+
+  returnDrawing(pen) {
+    if (this.drawingToPen.has(this.drawing) && this.drawingToPen.get(this.drawing) === pen) {
+      this.drawingToPen.delete(this.drawing);
+    }
+  }
+});
diff --git a/src/components/tools/networked-drawing.js b/src/components/tools/networked-drawing.js
new file mode 100644
index 0000000000000000000000000000000000000000..518de9cd82489111ce6585861df99cfd26776015
--- /dev/null
+++ b/src/components/tools/networked-drawing.js
@@ -0,0 +1,540 @@
+/* global THREE */
+/**
+ * Networked Drawing
+ * Creates procedurally generated 'lines' (or tubes) that are networked.
+ * @namespace drawing
+ * @component networked-drawing
+ */
+
+import SharedBufferGeometryManager from "../../utils/sharedbuffergeometrymanager";
+
+const MSG_CONFIRM_CONNECT = 0;
+const MSG_BUFFER_DATA = 1;
+const MSG_BUFFER_DATA_FULL = 2;
+
+function copyData(fromArray, toArray, fromIndex, toIndex) {
+  let i = fromIndex - 1;
+  let j = -1;
+  while (i + 1 <= toIndex) {
+    toArray[++j] = fromArray[++i];
+  }
+}
+
+AFRAME.registerComponent("networked-drawing", {
+  schema: {
+    segments: { default: 8 }, //the number of "sides" the procedural tube should have
+    defaultRadius: { default: 0.01 }, //the radius of the procedural tube
+    maxDrawTimeout: { default: 600000 }, //the maximum time a drawn line will live
+    maxLines: { default: 50 }, //how many lines can persist before lines older than minDrawTime are removed
+    maxPointsPerLine: { default: 250 } //the max number of points a single line can have
+  },
+
+  init() {
+    this._receiveData = this._receiveData.bind(this);
+
+    this.networkBuffer = [];
+
+    this.sendNetworkBufferQueue = [];
+
+    this.drawStarted = false;
+    this.lineStarted = false;
+    this.remoteLineStarted = false;
+
+    this.receivedBufferParts = 0;
+    this.bufferIndex = 0;
+    this.connectedToOwner = false;
+    this.networkBufferInitialized = false;
+
+    const options = {
+      vertexColors: THREE.VertexColors
+    };
+
+    this.color = new THREE.Color();
+    this.radius = this.data.defaultRadius;
+    this.segments = this.data.segments;
+
+    const material = new THREE.MeshStandardMaterial(options);
+    this.sharedBufferGeometryManager = new SharedBufferGeometryManager();
+    // NOTE: 20 is approximate for how many floats per point are added.
+    // maxLines + 1 because a line can be currently drawing while at maxLines.
+    // Multiply by 1/3 (0.333) because 3 floats per vertex (x, y, z).
+    const maxBufferSize = Math.round(this.data.maxPointsPerLine * 20 * (this.data.maxLines + 1) * 0.333);
+    this.sharedBufferGeometryManager.addSharedBuffer(0, material, THREE.TriangleStripDrawMode, maxBufferSize);
+
+    this.lastPoint = new THREE.Vector3();
+
+    this.lastSegments = [];
+    this.currentSegments = [];
+    for (let x = 0; x < this.segments; x++) {
+      this.lastSegments[x] = {
+        position: new THREE.Vector3(),
+        normal: new THREE.Vector3()
+      };
+      this.currentSegments[x] = {
+        position: new THREE.Vector3(),
+        normal: new THREE.Vector3()
+      };
+    }
+
+    this.sharedBuffer = this.sharedBufferGeometryManager.getSharedBuffer(0);
+    this.drawing = this.sharedBuffer.drawing;
+    const sceneEl = document.querySelector("a-scene");
+    this.scene = sceneEl.object3D;
+    this.scene.add(this.drawing);
+
+    this.prevIdx = Object.assign({}, this.sharedBuffer.idx);
+    this.idx = Object.assign({}, this.sharedBuffer.idx);
+    this.vertexCount = 0; //number of vertices added for current line (used for line deletion).
+    this.networkBufferCount = 0; //number of items added to networkBuffer for current line (used for line deletion).
+    this.currentPointCount = 0; //number of points added for current line (used for maxPointsPerLine).
+    this.networkBufferHistory = []; //tracks vertexCount and networkBufferCount so that lines can be deleted.
+
+    NAF.connection.onConnect(() => {
+      NAF.utils.getNetworkedEntity(this.el).then(networkedEl => {
+        this.networkedEl = networkedEl;
+        this.networkId = NAF.utils.getNetworkId(this.networkedEl);
+        this.drawingId = "drawing-" + this.networkId;
+        NAF.connection.subscribeToDataChannel(this.drawingId, this._receiveData);
+      });
+    });
+  },
+
+  remove() {
+    NAF.connection.unsubscribeToDataChannel(this.drawingId, this._receiveData);
+
+    this.scene.remove(this.drawing);
+  },
+
+  tick() {
+    const connected = NAF.connection.isConnected() && this.networkedEl;
+    const isMine = connected && NAF.utils.isMine(this.networkedEl);
+
+    if (!this.connectedToOwner && connected) {
+      const owner = NAF.utils.getNetworkOwner(this.networkedEl);
+      if (!isMine && NAF.connection.hasActiveDataChannel(owner)) {
+        NAF.connection.sendDataGuaranteed(owner, this.drawingId, {
+          type: MSG_CONFIRM_CONNECT,
+          clientId: NAF.clientId
+        });
+        this.connectedToOwner = true;
+      }
+    }
+
+    if (this.networkBuffer.length > 0 && connected) {
+      if (!isMine) {
+        this._drawFromNetwork();
+      } else if (this.bufferIndex < this.networkBuffer.length) {
+        this._broadcastDrawing();
+      }
+    }
+
+    //TODO: handle possibility that a clientId gets stuck in sendNetworkBufferQueue
+    //if that client disconnects before this executes and an activeDataChannel is opened.
+    if (isMine && this.sendNetworkBufferQueue.length > 0) {
+      const connected = [];
+      for (let i = 0; i < this.sendNetworkBufferQueue.length; i++) {
+        if (NAF.connection.hasActiveDataChannel(this.sendNetworkBufferQueue[i])) {
+          connected.push(this.sendNetworkBufferQueue[i]);
+        }
+      }
+      for (let j = 0; j < connected.length; j++) {
+        const pos = this.sendNetworkBufferQueue.indexOf(connected[j]);
+        this._sendNetworkBuffer(connected[j]);
+        this.sendNetworkBufferQueue.splice(pos, 1);
+      }
+    }
+
+    this._deleteLines();
+  },
+
+  _broadcastDrawing: (() => {
+    const copyArray = [];
+    return function() {
+      copyArray.length = 0;
+      copyData(this.networkBuffer, copyArray, this.bufferIndex, this.networkBuffer.length - 1);
+      this.bufferIndex = this.networkBuffer.length;
+      NAF.connection.broadcastDataGuaranteed(this.drawingId, { type: MSG_BUFFER_DATA, buffer: copyArray });
+    };
+  })(),
+
+  _drawFromNetwork: (() => {
+    const position = new THREE.Vector3();
+    const direction = new THREE.Vector3();
+    const normal = new THREE.Vector3();
+    return function() {
+      const head = this.networkBuffer[0];
+      let didWork = false;
+      while (head != null && this.networkBuffer.length >= 10) {
+        position.set(this.networkBuffer[0], this.networkBuffer[1], this.networkBuffer[2]);
+        direction.set(this.networkBuffer[3], this.networkBuffer[4], this.networkBuffer[5]);
+        this.radius = Math.round(direction.length() * 1000) / 1000; //radius is encoded as length of direction vector
+        direction.normalize();
+        normal.set(this.networkBuffer[6], this.networkBuffer[7], this.networkBuffer[8]);
+        this.color.setHex(Math.round(normal.length()) - 1); //color is encoded as length of normal vector
+        normal.normalize();
+
+        this.networkBuffer.splice(0, 9);
+
+        if (!this.remoteLineStarted) {
+          this.startDraw(position, direction, normal);
+          this.remoteLineStarted = true;
+        }
+
+        if (this.networkBuffer[0] === null) {
+          this._endDraw(position, direction, normal);
+          this.remoteLineStarted = false;
+          this.networkBuffer.shift();
+        } else {
+          this._draw(position, direction, normal);
+          didWork = true;
+        }
+      }
+      if (didWork) this._updateBuffer();
+    };
+  })(),
+
+  _deleteLines() {
+    const length = this.networkBufferHistory.length;
+    if (length > 0) {
+      const now = Date.now();
+      const time = this.networkBufferHistory[0].time;
+      if (length > this.data.maxLines || time + this.data.maxDrawTimeout <= now) {
+        const datum = this.networkBufferHistory[0];
+        if (length > 1) {
+          datum.idxLength += 2 - (this.segments % 2);
+          this.networkBufferHistory[1].idxLength -= 2 - (this.segments % 2);
+        }
+        this.idx.position = datum.idxLength;
+        this.idx.uv = datum.idxLength;
+        this.idx.normal = datum.idxLength;
+        this.idx.color = datum.idxLength;
+        this.sharedBuffer.remove(this.prevIdx, this.idx);
+        this.networkBufferHistory.shift();
+        if (this.networkedEl && NAF.utils.isMine(this.networkedEl)) {
+          this.networkBuffer.splice(0, datum.networkBufferCount);
+          this.bufferIndex -= datum.networkBufferCount;
+        }
+      }
+    }
+  },
+
+  _sendNetworkBuffer: (() => {
+    const copyArray = [];
+    //This number needs to be approx. < ~6000 based on napkin math
+    //see: https://github.com/webrtc/adapter/blob/682e0f2439e139da6c0c406370eae820637b8sc1a/src/js/common_shim.js#L157
+    const chunkAmount = 3000;
+    return function(clientId) {
+      if (NAF.utils.isMine(this.networkedEl)) {
+        if (this.networkBuffer.length <= chunkAmount) {
+          NAF.connection.sendDataGuaranteed(clientId, this.drawingId, {
+            type: MSG_BUFFER_DATA_FULL,
+            parts: 1,
+            buffer: this.networkBuffer
+          });
+        } else {
+          let start = 0;
+          let end = 0;
+          while (end < this.networkBuffer.length) {
+            end = Math.min(end + chunkAmount, this.networkBuffer.length);
+            copyArray.length = 0;
+            copyData(this.networkBuffer, copyArray, start, end - 1);
+            start = end;
+            NAF.connection.sendDataGuaranteed(clientId, this.drawingId, {
+              type: MSG_BUFFER_DATA_FULL,
+              parts: Math.ceil(this.networkBuffer.length / chunkAmount),
+              buffer: copyArray
+            });
+          }
+        }
+      }
+    };
+  })(),
+
+  _receiveData(_, dataType, data) {
+    switch (data.type) {
+      case MSG_CONFIRM_CONNECT:
+        this.sendNetworkBufferQueue.push(data.clientId);
+        break;
+      case MSG_BUFFER_DATA:
+        if (this.networkBufferInitialized) {
+          this.networkBuffer.push.apply(this.networkBuffer, data.buffer);
+        }
+        break;
+      case MSG_BUFFER_DATA_FULL:
+        this.networkBuffer.push.apply(this.networkBuffer, data.buffer);
+        if (++this.receivedBufferParts >= data.parts) {
+          this.networkBufferInitialized = true;
+        }
+        break;
+    }
+  },
+
+  getLastPoint() {
+    return this.lastPoint;
+  },
+
+  startDraw(position, direction, normal, color, radius) {
+    if (!NAF.connection.isConnected()) {
+      return;
+    }
+
+    this.drawStarted = true;
+
+    if (color) {
+      this.color.set(color);
+    }
+    if (radius) this.radius = radius;
+
+    this.lastPoint.copy(position);
+    this._addToNetworkBuffer(position, direction, normal);
+  },
+
+  draw(position, direction, normal, color, radius) {
+    if (!NAF.connection.isConnected() || !this.drawStarted) {
+      return;
+    }
+
+    if (color && color != "#" + this.color.getHexString().toUpperCase()) {
+      this.color.set(color);
+    }
+    if (radius) this.radius = radius;
+
+    this._addToNetworkBuffer(position, direction, normal);
+    this._draw(position, direction, normal);
+
+    this._updateBuffer();
+  },
+
+  _draw: (() => {
+    const capNormal = new THREE.Vector3();
+    return function(position, direction, normal, radiusMultiplier = 1.0) {
+      if (!this.lineStarted) {
+        this._generateSegments(this.lastSegments, position, direction, normal, this.radius * radiusMultiplier);
+
+        if (this.networkBufferHistory.length === 0) {
+          //start with CW faceculling order
+          this._addDegenerateTriangle();
+        } else {
+          //only do the following if the sharedBuffer is not empty
+          this._restartPrimitive();
+          this._addDegenerateTriangle();
+          if (this.segments % 2 === 0) {
+            //flip faceculling order if even numbered segments
+            this._addDegenerateTriangle();
+          }
+        }
+
+        //get normal for tip of cap
+        capNormal.copy(direction).negate();
+        //get normals for rim of cap
+        for (let i = 0; i < this.segments; i++) {
+          this.lastSegments[i].normal.add(capNormal).multiplyScalar(0.5);
+        }
+
+        this._drawCap(this.lastPoint, this.lastSegments, capNormal);
+        if (this.segments % 2 !== 0) {
+          //flip faceculling order if odd numbered segments
+          this._addDegenerateTriangle();
+        }
+
+        this.lineStarted = true;
+      } else {
+        this._generateSegments(this.currentSegments, position, direction, normal, this.radius * radiusMultiplier);
+        this._drawCylinder();
+
+        if (this.currentPointCount > this.data.maxPointsPerLine) {
+          this._drawEndCap(position, direction);
+          this._endLine();
+        }
+      }
+      this.lastPoint.copy(position);
+    };
+  })(),
+
+  endDraw(position, direction, normal) {
+    this._endDraw(position, direction, normal);
+    this._updateBuffer();
+  },
+
+  _endDraw(position, direction, normal) {
+    if (!this.lineStarted && this.drawStarted) {
+      this._drawPoint(position);
+    } else if (this.lineStarted && this.drawStarted) {
+      this._addToNetworkBuffer(position, direction, normal);
+      this._draw(position, direction, normal);
+      this._drawEndCap(position, direction);
+    }
+    this._endLine();
+  },
+
+  _drawEndCap: (() => {
+    const projectedDirection = new THREE.Vector3();
+    const projectedPoint = new THREE.Vector3();
+    return function(position, direction) {
+      if (this.lineStarted && this.drawStarted) {
+        projectedDirection.copy(direction).multiplyScalar(this.radius);
+        projectedPoint.copy(position).add(projectedDirection);
+        this._addDegenerateTriangle(); //flip faceculling order before drawing end-cap
+        this._drawCap(projectedPoint, this.lastSegments, direction);
+      }
+    };
+  })(),
+
+  _endLine() {
+    if (!this.drawStarted) return;
+
+    if (this.networkedEl && NAF.utils.isMine(this.networkedEl)) this._pushToNetworkBuffer(null);
+
+    const datum = {
+      networkBufferCount: this.networkBufferCount,
+      idxLength: this.vertexCount - 1,
+      time: Date.now()
+    };
+    this.networkBufferHistory.push(datum);
+    this.vertexCount = 0;
+    this.networkBufferCount = 0;
+    this.currentPointCount = 0;
+    this.lineStarted = false;
+    this.drawStarted = false;
+  },
+
+  _addToNetworkBuffer: (() => {
+    const copyDirection = new THREE.Vector3();
+    const copyNormal = new THREE.Vector3();
+    return function(position, direction, normal) {
+      if (this.networkedEl && NAF.utils.isMine(this.networkedEl)) {
+        ++this.currentPointCount;
+        this._pushToNetworkBuffer(position.x);
+        this._pushToNetworkBuffer(position.y);
+        this._pushToNetworkBuffer(position.z);
+        copyDirection.copy(direction);
+        copyDirection.setLength(this.radius); //encode radius as length of direction vector
+        this._pushToNetworkBuffer(copyDirection.x);
+        this._pushToNetworkBuffer(copyDirection.y);
+        this._pushToNetworkBuffer(copyDirection.z);
+        copyNormal.copy(normal);
+        copyNormal.setLength(this.color.getHex() + 1); //encode color as length, add one in case color is black
+        this._pushToNetworkBuffer(copyNormal.x);
+        this._pushToNetworkBuffer(copyNormal.y);
+        this._pushToNetworkBuffer(copyNormal.z);
+      }
+    };
+  })(),
+
+  _pushToNetworkBuffer(val) {
+    ++this.networkBufferCount;
+    this.networkBuffer.push(val);
+  },
+
+  //draw a cylinder from last to current segments
+  _drawCylinder() {
+    //average the normals with the normals from the lastSegment
+    //not a perfect normal calculation, but works well enough
+    for (let i = 0; i < this.segments; i++) {
+      this.currentSegments[i].normal.add(this.lastSegments[i].normal).multiplyScalar(0.5);
+    }
+
+    for (let i = 0; i != this.segments + 1; i++) {
+      this._addVertex(this.lastSegments[i % this.segments]);
+      this._addVertex(this.currentSegments[i % this.segments]);
+    }
+
+    for (let i = 0; i < this.segments; i++) {
+      this.lastSegments[i].position.copy(this.currentSegments[i].position);
+      this.lastSegments[i].normal.copy(this.currentSegments[i].normal);
+    }
+  },
+
+  //draw a standalone point in space
+  _drawPoint: (() => {
+    const up = new THREE.Vector3(0, 1, 0);
+    const down = new THREE.Vector3(0, -1, 0);
+    const left = new THREE.Vector3(1, 0, 0);
+    const projectedDirection = new THREE.Vector3();
+    const projectedPoint = new THREE.Vector3();
+    return function(position) {
+      projectedDirection.copy(up).multiplyScalar(this.radius * 0.75);
+      projectedPoint.copy(position).add(projectedDirection);
+      this.lastPoint.copy(projectedPoint);
+
+      projectedDirection.copy(up).multiplyScalar(this.radius * 0.5);
+      projectedPoint.copy(position).add(projectedDirection);
+      this._draw(projectedPoint, down, left, 0.75);
+
+      this._draw(position, down, left);
+
+      projectedDirection.copy(down).multiplyScalar(this.radius * 0.5);
+      projectedPoint.copy(position).add(projectedDirection);
+      this._draw(projectedPoint, down, left, 0.75);
+
+      projectedDirection.copy(down).multiplyScalar(this.radius * 0.75);
+      projectedPoint.copy(position).add(projectedDirection);
+
+      this._addDegenerateTriangle(); //discarded
+      this._drawCap(projectedPoint, this.lastSegments, down);
+    };
+  })(),
+
+  //draw a cap to start/end a line
+  _drawCap(point, segments, normal) {
+    let segmentIndex = 0;
+    for (let i = 0; i < this.segments * 2 - (this.segments % 2); i++) {
+      if ((i - 2) % 4 === 0) {
+        this._addVertex({ position: point, normal: normal });
+      } else {
+        this._addVertex(segments[segmentIndex % this.segments]);
+        if ((i + 1) % 5 !== 0) {
+          ++segmentIndex;
+        }
+      }
+    }
+  },
+
+  _restartPrimitive() {
+    this.sharedBuffer.restartPrimitive();
+    ++this.vertexCount;
+  },
+
+  _updateBuffer() {
+    this.sharedBuffer.update();
+  },
+
+  _addVertex(segment) {
+    const point = segment.position;
+    const normal = segment.normal;
+    this.sharedBuffer.addVertex(point.x, point.y, point.z);
+    this.sharedBuffer.addColor(this.color.r, this.color.g, this.color.b);
+
+    if (normal) {
+      this.sharedBuffer.addNormal(normal.x, normal.y, normal.z);
+    } else {
+      ++this.sharedBuffer.idx.normal;
+    }
+
+    ++this.sharedBuffer.idx.uv;
+    ++this.vertexCount;
+  },
+
+  _addDegenerateTriangle() {
+    this._addVertex(this.lastSegments[0]);
+  },
+
+  //calculate the segments for a given point
+  _generateSegments(segmentsList, point, forward, up, radius) {
+    const angleIncrement = (Math.PI * 2) / this.segments;
+    for (let i = 0; i < this.segments; i++) {
+      const segment = segmentsList[i].position;
+      this._rotatePointAroundAxis(segment, point, forward, up, angleIncrement * i, radius);
+      segmentsList[i].normal.subVectors(segment, point).normalize();
+    }
+  },
+
+  _rotatePointAroundAxis: (() => {
+    const calculatedDirection = new THREE.Vector3();
+    return function(out, point, axis, up, angle, radius) {
+      calculatedDirection.copy(up);
+      calculatedDirection.applyAxisAngle(axis, angle);
+      out.copy(point).add(calculatedDirection.normalize().multiplyScalar(radius));
+    };
+  })()
+});
diff --git a/src/components/tools/pen.js b/src/components/tools/pen.js
new file mode 100644
index 0000000000000000000000000000000000000000..855c94e0a1d11f5905dd20f55acdda27ebad227d
--- /dev/null
+++ b/src/components/tools/pen.js
@@ -0,0 +1,188 @@
+/**
+ * Pen tool
+ * A tool that allows drawing on networked-drawing components.
+ * @namespace drawing
+ * @component pen
+ */
+
+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("pen", {
+  schema: {
+    drawFrequency: { default: 5 }, //frequency of polling for drawing points
+    minDistanceBetweenPoints: { default: 0.01 }, //minimum distance to register new drawing point
+    camera: { type: "selector" },
+    drawingManager: { type: "string" },
+    color: { type: "color", default: "#FF0033" },
+    availableColors: {
+      default: [
+        "#FF0033",
+        "#FFFF00",
+        "#0099FF",
+        "#00FF33",
+        "#9900FF",
+        "#FF6600",
+        "#8D5524",
+        "#C68642",
+        "#E0AC69",
+        "#F1C27D",
+        "#FFDBAC",
+        "#FFFFFF",
+        "#222222",
+        "#111111",
+        "#000000"
+      ]
+    },
+    radius: { default: 0.01 }, //drawing geometry radius
+    minRadius: { default: 0.005 },
+    maxRadius: { default: 0.2 }
+  },
+
+  init() {
+    this._stateAdded = this._stateAdded.bind(this);
+    this._stateRemoved = this._stateRemoved.bind(this);
+
+    this.timeSinceLastDraw = 0;
+
+    this.lastPosition = new THREE.Vector3();
+    this.lastPosition.copy(this.el.object3D.position);
+
+    this.direction = new THREE.Vector3(1, 0, 0);
+
+    this.currentDrawing = null;
+
+    this.normal = new THREE.Vector3();
+
+    this.worldPosition = new THREE.Vector3();
+
+    this.colorIndex = 0;
+
+    this.grabbed = false;
+  },
+
+  play() {
+    this.drawingManager = document.querySelector(this.data.drawingManager).components["drawing-manager"];
+    this.drawingManager.createDrawing();
+
+    this.el.parentNode.addEventListener("stateadded", this._stateAdded);
+    this.el.parentNode.addEventListener("stateremoved", this._stateRemoved);
+  },
+
+  pause() {
+    this.el.parentNode.removeEventListener("stateadded", this._stateAdded);
+    this.el.parentNode.removeEventListener("stateremoved", this._stateRemoved);
+  },
+
+  update(prevData) {
+    if (prevData.color != this.data.color) {
+      this.el.setAttribute("color", this.data.color);
+    }
+    if (prevData.radius != this.data.radius) {
+      this.el.setAttribute("radius", this.data.radius);
+    }
+  },
+
+  tick(t, dt) {
+    this.el.object3D.getWorldPosition(this.worldPosition);
+
+    if (!almostEquals(0.005, this.worldPosition, this.lastPosition)) {
+      this.direction.subVectors(this.worldPosition, this.lastPosition).normalize();
+      this.lastPosition.copy(this.worldPosition);
+    }
+
+    if (this.currentDrawing) {
+      const time = this.timeSinceLastDraw + dt;
+      if (
+        time >= this.data.drawFrequency &&
+        this.currentDrawing.getLastPoint().distanceTo(this.worldPosition) >= this.data.minDistanceBetweenPoints
+      ) {
+        this._getNormal(this.normal, this.worldPosition, this.direction);
+        this.currentDrawing.draw(this.worldPosition, this.direction, this.normal, this.data.color, this.data.radius);
+      }
+
+      this.timeSinceLastDraw = time % this.data.drawFrequency;
+    }
+  },
+
+  //helper function to get normal of direction of drawing cross direction to camera
+  _getNormal: (() => {
+    const directionToCamera = new THREE.Vector3();
+    return function(normal, position, direction) {
+      directionToCamera.subVectors(position, this.data.camera.object3D.position).normalize();
+      normal.crossVectors(direction, directionToCamera);
+    };
+  })(),
+
+  _startDraw() {
+    this.currentDrawing = this.drawingManager.getDrawing(this);
+    if (this.currentDrawing) {
+      this.el.object3D.getWorldPosition(this.worldPosition);
+      this._getNormal(this.normal, this.worldPosition, this.direction);
+
+      this.currentDrawing.startDraw(this.worldPosition, this.direction, this.normal, this.data.color, this.data.radius);
+    }
+  },
+
+  _endDraw() {
+    if (this.currentDrawing) {
+      this.timeSinceLastDraw = 0;
+      this.el.object3D.getWorldPosition(this.worldPosition);
+      this._getNormal(this.normal, this.worldPosition, this.direction);
+      this.currentDrawing.endDraw(this.worldPosition, this.direction, this.normal);
+      this.drawingManager.returnDrawing(this);
+      this.currentDrawing = null;
+    }
+  },
+
+  _changeColor(mod) {
+    this.colorIndex = (this.colorIndex + mod + this.data.availableColors.length) % this.data.availableColors.length;
+    this.data.color = this.data.availableColors[this.colorIndex];
+    this.el.setAttribute("color", this.data.color);
+  },
+
+  _changeRadius(mod) {
+    this.data.radius = Math.max(this.data.minRadius, Math.min(this.data.radius + mod, this.data.maxRadius));
+    this.el.setAttribute("radius", this.data.radius);
+  },
+
+  _stateAdded(evt) {
+    switch (evt.detail) {
+      case "activated":
+        this._startDraw();
+        break;
+      case "colorNext":
+        this._changeColor(1);
+        break;
+      case "colorPrev":
+        this._changeColor(-1);
+        break;
+      case "radiusUp":
+        this._changeRadius(this.data.minRadius);
+        break;
+      case "radiusDown":
+        this._changeRadius(-this.data.minRadius);
+        break;
+      case "grabbed":
+        this.grabbed = true;
+        break;
+      default:
+        break;
+    }
+  },
+
+  _stateRemoved(evt) {
+    switch (evt.detail) {
+      case "activated":
+        this._endDraw();
+        break;
+      case "grabbed":
+        this.grabbed = false;
+        this._endDraw();
+        break;
+      default:
+        break;
+    }
+  }
+});
diff --git a/src/components/virtual-gamepad-controls.css b/src/components/virtual-gamepad-controls.css
index 4270c36ad7be261997f7ab77218e9ea817269620..3a5ed3c9c6aeb7ebda7a3b8f4cee610af2a78a57 100644
--- a/src/components/virtual-gamepad-controls.css
+++ b/src/components/virtual-gamepad-controls.css
@@ -6,11 +6,11 @@
 
 :local(.touchZone.left) {
   left: 0;
-  right: 50%;
+  right: 55%;
 }
 
 :local(.touchZone.right) {
-  left: 50%;
+  left: 55%;
   right: 0;
 }
 
diff --git a/src/gltf-component-mappings.js b/src/gltf-component-mappings.js
index 71b9cbff5b20df5eb8935664f1ae45f5b28bb478..5dde948e65abc8b1ce5ea461a4ac38994047b88c 100644
--- a/src/gltf-component-mappings.js
+++ b/src/gltf-component-mappings.js
@@ -1,5 +1,6 @@
 import "./components/gltf-model-plus";
 
+AFRAME.GLTFModelPlus.registerComponent("duck", "duck");
 AFRAME.GLTFModelPlus.registerComponent("quack", "quack");
 AFRAME.GLTFModelPlus.registerComponent("sound", "sound");
 AFRAME.GLTFModelPlus.registerComponent("collision-filter", "collision-filter");
diff --git a/src/hub.html b/src/hub.html
index 9b9161d2a27f6505f9d105b0bd3a1d9dde1831a5..787984a1889fecaf5f2aaa53ad3efe53c539e8c4 100644
--- a/src/hub.html
+++ b/src/hub.html
@@ -56,6 +56,8 @@
             <img id="freeze-off-hover" crossorigin="anonymous" src="./assets/hud/freeze_off-hover.png">
             <img id="freeze-on" crossorigin="anonymous" src="./assets/hud/freeze_on.png">
             <img id="freeze-on-hover" crossorigin="anonymous" src="./assets/hud/freeze_on-hover.png">
+            <img id="spawn-pen" crossorigin="anonymous" src="./assets/hud/spawn_pen.png">
+            <img id="spawn-pen-hover" crossorigin="anonymous" src="./assets/hud/spawn_pen-hover.png">
 
             <a-asset-item id="botdefault" response-type="arraybuffer" src="https://asset-bundles-prod.reticulum.io/bots/BotDefault_Avatar-9f71f8ff22.gltf"></a-asset-item>
             <a-asset-item id="botbobo" response-type="arraybuffer" src="https://asset-bundles-prod.reticulum.io/bots/BotBobo_Avatar-f9740a010b.gltf"></a-asset-item>
@@ -69,7 +71,6 @@
             <a-asset-item id="botwoody" response-type="arraybuffer" src="https://asset-bundles-prod.reticulum.io/bots/BotWoody_Avatar-0140485a23.gltf"></a-asset-item>
 
             <a-asset-item id="watch-model" response-type="arraybuffer" src="./assets/hud/watch.glb"></a-asset-item>
-            <a-asset-item id="interactable-duck" response-type="arraybuffer" src="https://asset-bundles-prod.reticulum.io/interactables/Ducky/DuckyMesh-438ff8e022.gltf"></a-asset-item>
 
             <a-asset-item id="quack" src="./assets/sfx/quack.mp3" response-type="arraybuffer" preload="auto"></a-asset-item>
             <a-asset-item id="specialquack" src="./assets/sfx/specialquack.mp3" response-type="arraybuffer" preload="auto"></a-asset-item>
@@ -156,6 +157,8 @@
                     position-at-box-shape-border="target:.delete-button"
                     destroy-at-extreme-distances
                     rotation
+                    activatable__increase-scale="buttonStartEvents: scroll_right; buttonEndEvents: horizontal_scroll_release; activatedState: scaleUp;"
+                    activatable__decrease-scale="buttonStartEvents: scroll_left; buttonEndEvents: horizontal_scroll_release; activatedState: scaleDown;"
                 >
                     <!-- HACK: rotation component above is required for its side effect of setting YXZ order -->
                     <a-entity class="delete-button" visible-while-frozen>
@@ -165,6 +168,46 @@
                 </a-entity>
             </template>
 
+            <template id="pen-interactable">
+                <a-entity
+                    class="interactable toggle"
+                    super-networked-interactable="counter: #pen-counter;"
+                    body="type: dynamic; shape: none; mass: 1;"
+                    grabbable-toggle="maxGrabbers: 1;"
+                    sticky-object="autoLockOnRelease: true; autoLockOnLoad: true;"
+                    hoverable
+                    activatable__draw-hand="buttonStartEvents: secondary_hand_grab; buttonEndEvents: secondary_hand_release;"
+                    activatable__draw-cursor="buttonStartEvents: secondary-cursor-grab; buttonEndEvents: secondary-cursor-release;"
+                    activatable__color-next="buttonStartEvents: next_color, scroll_right; buttonEndEvents: thumb_up, secondary_hand_release, horizontal_scroll_release; activatedState: colorNext;"
+                    activatable__color-prev="buttonStartEvents: previous_color, scroll_left; buttonEndEvents: thumb_up, secondary_hand_release, horizontal_scroll_release; activatedState: colorPrev;"
+                    activatable__increase-radius="buttonStartEvents: increase_radius, scroll_up; buttonEndEvents: thumb_up, secondary_hand_release, vertical_scroll_release; activatedState: radiusUp;"
+                    activatable__decrease-radius="buttonStartEvents: decrease_radius, scroll_down; buttonEndEvents: thumb_up, secondary_hand_release, vertical_scroll_release; activatedState: radiusDown;"
+                    scale="0.5 0.5 0.5"
+                >
+                    <a-sphere
+                        id="pen"
+                        scale="1.5, 1.5, 1.5"
+                        position="0 -0.18 0"
+                        radius="0.02"
+                        color="#FF0033"
+                        pen="camera: #player-camera; drawingManager: #drawing-manager"
+                        segments-width="16"
+                        segments-height="12"
+                    ></a-sphere>
+                    <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>
+
+
+            <template id="interactable-drawing">
+                <a-entity
+                    networked-drawing
+                ></a-entity>
+            </template>
+
             <template id="paging-toolbar">
                 <a-entity class="paging-toolbar" visible-to-owner>
                     <a-entity class="prev-button" position="-0.3 0 0">
@@ -199,11 +242,18 @@
 
             <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;"
+                         colliderEvent: collisions;
+                         colliderEventProperty: els;
+                         colliderEndEvent: collisions;
+                         colliderEndEventProperty: clearedEls;
+                         grabStartButtons: primary_hand_grab, secondary_hand_grab;
+                         grabEndButtons: primary_hand_release, secondary_hand_release;
+                         stretchStartButtons: primary_hand_grab, secondary_hand_grab;
+                         stretchEndButtons: primary_hand_release, secondary_hand_release;
+                         dragDropStartButtons: hand_grab, secondary_hand_grab;
+                         dragDropEndButtons: hand_release, secondary_hand_release;
+                         activateStartButtons: secondary_hand_grab, next_color, previous_color, increase_radius, decrease_radius, scroll_up, scroll_down, scroll_left, scroll_right;
+                         activateEndButtons: secondary_hand_release, vertical_scroll_release, horizontal_scroll_release, thumb_up;"
                      collision-filter="collisionForces: false"
                      physics-collider
             ></a-mixin>
@@ -212,12 +262,16 @@
         <!-- Interactables -->
         <a-entity id="media-counter" networked-counter="max: 10;"></a-entity>
 
+        <a-entity id="pen-counter" networked-counter="max: 10;"></a-entity>
+
+        <a-entity id="drawing-manager" drawing-manager></a-entity>
+
         <a-entity
             id="cursor-controller"
             cursor-controller="
                 cursor: #cursor;
-                camera: #player-camera; "
-            raycaster="objects: .collidable, .interactable, .ui; far: 3;"
+                camera: #player-camera;
+                objects: .collidable, .interactable, .ui;"
             line="visible: false; color: white; opacity: 0.2;"
         ></a-entity>
 
@@ -225,17 +279,21 @@
             id="cursor"
             material="depthTest: false; opacity:0.9;"
             radius="0.02"
+            segments-width="9"
+            segments-height="9"
             static-body="shape: sphere;"
             collision-filter="collisionForces: false"
             super-hands="
-                colliderEvent: raycaster-intersection; colliderEventProperty: els;
-                colliderEndEvent: raycaster-intersection-cleared; colliderEndEventProperty: clearedEls;
-                grabStartButtons: cursor-grab; grabEndButtons: cursor-release;
-                stretchStartButtons: cursor-grab; stretchEndButtons: cursor-release;
-                dragDropStartButtons: cursor-grab; dragDropEndButtons: cursor-release;"
-            segments-height="9"
-            segments-width="9"
-            event-repeater="events: raycaster-intersection, raycaster-intersection-cleared; eventSource: #cursor-controller"
+                colliderEvent: raycaster-intersection;
+                colliderEndEvent: raycaster-intersection-cleared;
+                grabStartButtons: cursor-grab, primary_hand_grab, secondary_hand_grab;
+                grabEndButtons: cursor-release, primary_hand_release, secondary_hand_release;
+                stretchStartButtons: cursor-grab, primary_hand_grab, secondary_hand_grab;
+                stretchEndButtons: cursor-release, primary_hand_release, secondary_hand_release;
+                dragDropStartButtons: cursor-grab, primary_hand_grab, secondary_hand_grab;
+                dragDropEndButtons: cursor-release, primary_hand_release, secondary_hand_release;
+                activateStartButtons: secondary-cursor-grab, secondary_hand_grab, next_color, previous_color, increase_radius, decrease_radius, scroll_up, scroll_down, scroll_left, scroll_right;
+                activateEndButtons: secondary-cursor-release, secondary_hand_release, vertical_scroll_release, horizontal_scroll_release, thumb_up;"
         ></a-sphere>
 
         <!-- Player Rig -->
@@ -260,7 +318,7 @@
               <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-image icon-button="tooltip: #hud-tooltip; tooltipText: Spawn Pen; activeTooltipText: Spawn Pen; image: #spawn-pen; hoverImage: #spawn-pen-hover; activeImage: #spawn-pen; activeHoverImage: #spawn-pen-hover" scale="0.1 0.1 0.1" position="0.17 0 0.001" class="ui hud pen" 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>
@@ -272,7 +330,7 @@
               class="camera"
               camera
               position="0 1.6 0"
-              personal-space-bubble="radius: 0.4"
+              personal-space-bubble="radius: 0.4;"
               pitch-yaw-rotator
           >
             <a-entity
@@ -329,6 +387,7 @@
                   missOpacity: 0.1;
                   curveShootingSpeed: 12;"
               haptic-feedback
+              event-repeater="events: haptic_pulse; eventSource: #cursor"
               body="type: static; shape: none;"
               mixin="controller-super-hands"
               controls-shape-offset
@@ -376,6 +435,14 @@
             static-body="shape: none;"
         ></a-entity>
 
+        <a-entity
+        super-spawner="
+            template: #pen-interactable;
+            src: https://asset-bundles-prod.reticulum.io/interactables/DrawingPen/DrawingPen-34fb4aee27.gltf;
+            spawnEvent: spawn_pen;
+            superHand: #player-right-controller;
+            cursorSuperHand: #cursor;"
+        ></a-entity>
     </a-scene>
 
     <div id="ui-root"></div>
diff --git a/src/hub.js b/src/hub.js
index 8db01eaddef8c92fa5dd4a8da040fa2473615b56..6bc7382c84b8ec3cf3af71914a592ca853f76d1b 100644
--- a/src/hub.js
+++ b/src/hub.js
@@ -7,6 +7,7 @@ import "./utils/logging";
 import { patchWebGLRenderingContext } from "./utils/webgl";
 patchWebGLRenderingContext();
 
+import screenfull from "screenfull";
 import "three/examples/js/loaders/GLTFLoader";
 import "networked-aframe/src/index";
 import "naf-janus-adapter";
@@ -126,12 +127,18 @@ import "./components/event-repeater";
 import "./components/controls-shape-offset";
 import "./components/duck";
 import "./components/quack";
+import "./components/grabbable-toggle";
 
 import "./components/cardboard-controls";
 
 import "./components/cursor-controller";
 
 import "./components/nav-mesh-helper";
+import "./systems/tunnel-effect";
+
+import "./components/tools/pen";
+import "./components/tools/networked-drawing";
+import "./components/tools/drawing-manager";
 
 import registerNetworkSchemas from "./network-schemas";
 import { inGameActions, config as inputConfig } from "./input-mappings";
@@ -139,6 +146,7 @@ import registerTelemetry from "./telemetry";
 
 import { getAvailableVREntryTypes, VR_DEVICE_AVAILABILITY } from "./utils/vr-caps-detect.js";
 import ConcurrentLoadDetector from "./utils/concurrent-load-detector.js";
+
 import qsTruthy from "./utils/qs_truthy";
 
 const isBotMode = qsTruthy("bot");
@@ -189,6 +197,10 @@ function mountUI(scene, props = {}) {
   );
 }
 
+function requestFullscreen() {
+  if (screenfull.enabled && !screenfull.isFullscreen) screenfull.request();
+}
+
 const onReady = async () => {
   const scene = document.querySelector("a-scene");
   const hubChannel = new HubChannel(store);
@@ -233,10 +245,17 @@ const onReady = async () => {
       }
       document.body.removeChild(scene);
     }
+    document.body.removeEventListener("touchend", requestFullscreen);
   };
 
   const enterScene = async (mediaStream, enterInVR, hubId) => {
     const scene = document.querySelector("a-scene");
+
+    // Get aframe inspector url using the webpack file-loader.
+    const aframeInspectorUrl = require("file-loader?name=assets/js/[name]-[hash].[ext]!aframe-inspector/dist/aframe-inspector.min.js");
+    // Set the aframe-inspector url to our hosted copy.
+    scene.setAttribute("inspector", { url: aframeInspectorUrl });
+
     if (!isBotMode) {
       scene.classList.add("no-cursor");
     }
@@ -246,6 +265,8 @@ const onReady = async () => {
 
     if (enterInVR) {
       scene.enterVR();
+    } else if (AFRAME.utils.device.isMobile()) {
+      document.body.addEventListener("touchend", requestFullscreen);
     }
 
     AFRAME.registerInputActions(inGameActions, "default");
@@ -305,12 +326,16 @@ const onReady = async () => {
     });
 
     const offset = { x: 0, y: 0, z: -1.5 };
+
     const spawnMediaInfrontOfPlayer = (src, contentOrigin) => {
-      const entity = addMedia(src, contentOrigin, true);
+      const { entity, orientation } = addMedia(src, "#interactable-media", contentOrigin, true);
 
-      entity.setAttribute("offset-relative-to", {
-        target: "#player-camera",
-        offset
+      orientation.then(or => {
+        entity.setAttribute("offset-relative-to", {
+          target: "#player-camera",
+          offset,
+          orientation: or
+        });
       });
     };
 
@@ -536,7 +561,7 @@ const onReady = async () => {
       if (/\.gltf/i.test(sceneUrl) || /\.glb/i.test(sceneUrl)) {
         const resolved = await resolveMedia(sceneUrl, false, 0);
         const gltfEl = document.createElement("a-entity");
-        gltfEl.setAttribute("gltf-model-plus", { src: resolved.raw, inflate: true });
+        gltfEl.setAttribute("gltf-model-plus", { src: resolved.raw, useCache: false, inflate: true });
         gltfEl.addEventListener("model-loaded", () => initialEnvironmentEl.emit("bundleloaded"));
         initialEnvironmentEl.appendChild(gltfEl);
       } else {
diff --git a/src/index.js b/src/index.js
index 7fddba12131a6d46fea444a42b2001acfe7afeb7..fa64e8923edfb8f103f8ac0d98342e759ad59304 100644
--- a/src/index.js
+++ b/src/index.js
@@ -3,17 +3,23 @@ import React from "react";
 import ReactDOM from "react-dom";
 import registerTelemetry from "./telemetry";
 import HomeRoot from "./react-components/home-root";
-import InfoDialog from "./react-components/info-dialog.js";
 
 const qs = new URLSearchParams(location.search);
 registerTelemetry();
 
-ReactDOM.render(
+const { pathname } = document.location;
+const sceneId = qs.get("scene_id") || (pathname.startsWith("/scenes/") && pathname.substring(1).split("/")[1]);
+
+const root = (
   <HomeRoot
     initialEnvironment={qs.get("initial_environment")}
-    dialogType={
-      qs.has("list_signup") ? InfoDialog.dialogTypes.updates : qs.has("report") ? InfoDialog.dialogTypes.report : null
-    }
-  />,
-  document.getElementById("home-root")
+    sceneId={sceneId}
+    authVerify={qs.has("auth_topic")}
+    authTopic={qs.get("auth_topic")}
+    authToken={qs.get("auth_token")}
+    authOrigin={qs.get("auth_origin")}
+    listSignup={qs.has("list_signup")}
+    report={qs.has("report")}
+  />
 );
+ReactDOM.render(root, document.getElementById("home-root"));
diff --git a/src/input-mappings.js b/src/input-mappings.js
index ccf44110bc74e38ab80de143c11f2dcad50d2a5c..6b1b4e955b95acfd13c469804c2f1478fd1435a1 100644
--- a/src/input-mappings.js
+++ b/src/input-mappings.js
@@ -54,41 +54,37 @@ const config = {
         trackpad_dpad4_pressed_center_down: { right: "action_primary_down" },
         trackpad_dpad4_pressed_north_down: { right: "action_primary_down" },
         trackpad_dpad4_pressed_south_down: { right: "action_primary_down" },
-        trackpadup: { right: "action_primary_up" },
+        trackpadup: { left: "action_primary_up", right: "action_primary_up" },
         menudown: "thumb_down",
         menuup: "thumb_up",
-        gripdown: ["action_grab", "middle_ring_pinky_down"],
-        gripup: ["action_release", "middle_ring_pinky_up"],
+        gripdown: ["primary_action_grab", "middle_ring_pinky_down"],
+        gripup: ["primary_action_release", "middle_ring_pinky_up"],
         trackpadtouchstart: "thumb_down",
         trackpadtouchend: "thumb_up",
-        triggerdown: ["action_grab", "index_down"],
-        triggerup: ["action_release", "index_up"],
-        scroll: { right: "move_duck" }
+        triggerdown: ["secondary_action_grab", "index_down"],
+        triggerup: ["secondary_action_release", "index_up"],
+        scroll: { left: "scroll_move", right: "scroll_move" }
       },
       "oculus-touch-controls": {
-        joystick_dpad4_west: {
-          right: "snap_rotate_left"
-        },
-        joystick_dpad4_east: {
-          right: "snap_rotate_right"
-        },
-        gripdown: ["action_grab", "middle_ring_pinky_down"],
-        gripup: ["action_release", "middle_ring_pinky_up"],
-        abuttontouchstart: "thumb_down",
+        joystick_dpad4_west: { right: "snap_rotate_left" },
+        joystick_dpad4_east: { right: "snap_rotate_right" },
+        gripdown: ["primary_action_grab", "middle_ring_pinky_down"],
+        gripup: ["primary_action_release", "middle_ring_pinky_up"],
+        abuttontouchstart: ["thumb_down", "increase_radius"],
         abuttontouchend: "thumb_up",
-        bbuttontouchstart: "thumb_down",
+        bbuttontouchstart: ["thumb_down", "decrease_radius"],
         bbuttontouchend: "thumb_up",
-        xbuttontouchstart: "thumb_down",
+        xbuttontouchstart: ["thumb_down", "increase_radius"],
         xbuttontouchend: "thumb_up",
-        ybuttontouchstart: "thumb_down",
+        ybuttontouchstart: ["thumb_down", "decrease_radius"],
         ybuttontouchend: "thumb_up",
-        surfacetouchstart: "thumb_down",
+        surfacetouchstart: ["thumb_down", "next_color"],
         surfacetouchend: "thumb_up",
         thumbsticktouchstart: "thumb_down",
         thumbsticktouchend: "thumb_up",
-        triggerdown: ["action_grab", "index_down"],
-        triggerup: ["action_release", "index_up"],
-        "axismove.reverseY": { left: "move", right: "move_duck" },
+        triggerdown: ["secondary_action_grab", "index_down"],
+        triggerup: ["secondary_action_release", "index_up"],
+        "axismove.reverseY": { left: "move", right: "scroll_move" },
         abuttondown: "action_primary_down",
         abuttonup: "action_primary_up"
       },
@@ -103,16 +99,16 @@ const config = {
         joystick_dpad4_pressed_west_down: { right: "snap_rotate_left" },
         joystick_dpad4_pressed_east_down: { right: "snap_rotate_right" },
         trackpaddown: { right: "action_primary_down" },
-        trackpadup: { right: "action_primary_up" },
+        trackpadup: { left: "action_primary_up", right: "action_primary_up" },
         menudown: "thumb_down",
         menuup: "thumb_up",
-        gripdown: ["action_grab", "middle_ring_pinky_down"],
-        gripup: ["action_release", "middle_ring_pinky_up"],
+        gripdown: ["primary_action_grab", "middle_ring_pinky_down"],
+        gripup: ["primary_action_release", "middle_ring_pinky_up"],
         trackpadtouchstart: "thumb_down",
         trackpadtouchend: "thumb_up",
-        triggerdown: ["action_grab", "index_down"],
-        triggerup: ["action_release", "index_up"],
-        axisMoveWithDeadzone: { left: "move", right: "move_duck" }
+        triggerdown: ["secondary_action_grab", "index_down"],
+        triggerup: ["secondary_action_release", "index_up"],
+        axisMoveWithDeadzone: { left: "move", right: "scroll_move" }
       },
       "daydream-controls": {
         trackpad_dpad4_pressed_west_down: "snap_rotate_left",
@@ -121,7 +117,7 @@ const config = {
         trackpad_dpad4_pressed_north_down: ["action_primary_down"],
         trackpad_dpad4_pressed_south_down: ["action_primary_down"],
         trackpadup: ["action_primary_up"],
-        axisMoveWithDeadzone: "move_duck"
+        axisMoveWithDeadzone: "scroll_move"
       },
       "gearvr-controls": {
         trackpad_dpad4_pressed_west_down: "snap_rotate_left",
@@ -130,9 +126,9 @@ const config = {
         trackpad_dpad4_pressed_north_down: ["action_primary_down"],
         trackpad_dpad4_pressed_south_down: ["action_primary_down"],
         trackpadup: ["action_primary_up"],
-        triggerdown: ["action_primary_down"],
-        triggerup: ["action_primary_up"],
-        scroll: "move_duck"
+        triggerdown: ["action_secondary_down"],
+        triggerup: ["action_secondary_up"],
+        scroll: "scroll_move"
       },
       "oculus-go-controls": {
         trackpad_dpad4_pressed_west_down: "snap_rotate_left",
@@ -141,9 +137,9 @@ const config = {
         trackpad_dpad4_pressed_north_down: ["action_primary_down"],
         trackpad_dpad4_pressed_south_down: ["action_primary_down"],
         trackpadup: ["action_primary_up"],
-        triggerdown: ["action_primary_down"],
-        triggerup: ["action_primary_up"],
-        scroll: "move_duck"
+        triggerdown: ["action_secondary_down"],
+        triggerup: ["action_secondary_up"],
+        scroll: "scroll_move"
       },
       keyboard: {
         m_press: "action_mute",
@@ -170,44 +166,6 @@ const config = {
         arrowright_down: "d_down",
         arrowright_up: "d_up"
       }
-    },
-    hud: {
-      "vive-controls": {
-        triggerdown: ["action_grab", "index_down"],
-        triggerup: ["action_release", "index_up"]
-      },
-      "oculus-touch-controls": {
-        abuttondown: "action_ui_select_down",
-        abuttonup: "action_ui_select_up",
-        gripdown: "middle_ring_pinky_down",
-        gripup: "middle_ring_pinky_up",
-        abuttontouchstart: "thumb_down",
-        abuttontouchend: "thumb_up",
-        bbuttontouchstart: "thumb_down",
-        bbuttontouchend: "thumb_up",
-        xbuttontouchstart: "thumb_down",
-        xbuttontouchend: "thumb_up",
-        ybuttontouchstart: "thumb_down",
-        ybuttontouchend: "thumb_up",
-        surfacetouchstart: "thumb_down",
-        surfacetouchend: "thumb_up",
-        thumbsticktouchstart: "thumb_down",
-        thumbsticktouchend: "thumb_up",
-        triggertouchstart: "index_down",
-        triggertouchend: "index_up"
-      },
-      "daydream-controls": {
-        trackpaddown: { right: "action_ui_select_down" },
-        trackpadup: { right: "action_ui_select_up" }
-      },
-      "gearvr-controls": {
-        trackpaddown: { right: "action_ui_select_down" },
-        trackpadup: { right: "action_ui_select_up" }
-      },
-      "oculus-go-controls": {
-        trackpaddown: { right: "action_ui_select_down" },
-        trackpadup: { right: "action_ui_select_up" }
-      }
     }
   }
 };
diff --git a/src/network-schemas.js b/src/network-schemas.js
index 4c8ac07e05625edffe916a549d96ea0a95bc297f..e639aa899a1e931df1b97b25761a4c7080d55a83 100644
--- a/src/network-schemas.js
+++ b/src/network-schemas.js
@@ -72,10 +72,12 @@ function registerNetworkSchemas() {
     template: "#video-template",
     components: [
       {
-        component: "position"
+        component: "position",
+        requiresNetworkUpdate: vectorRequiresUpdate(0.001)
       },
       {
-        component: "rotation"
+        component: "rotation",
+        requiresNetworkUpdate: vectorRequiresUpdate(0.5)
       },
       "visible"
     ]
@@ -104,6 +106,48 @@ function registerNetworkSchemas() {
       }
     ]
   });
+
+  NAF.schemas.add({
+    template: "#interactable-drawing",
+    components: [
+      {
+        component: "position",
+        requiresNetworkUpdate: vectorRequiresUpdate(0.001)
+      },
+      {
+        component: "rotation",
+        requiresNetworkUpdate: vectorRequiresUpdate(0.5)
+      },
+      "scale",
+      "networked-drawing"
+    ]
+  });
+
+  NAF.schemas.add({
+    template: "#pen-interactable",
+    components: [
+      {
+        component: "position",
+        requiresNetworkUpdate: vectorRequiresUpdate(0.001)
+      },
+      {
+        component: "rotation",
+        requiresNetworkUpdate: vectorRequiresUpdate(0.5)
+      },
+      "scale",
+      "media-loader",
+      {
+        selector: "#pen",
+        component: "pen",
+        property: "radius"
+      },
+      {
+        selector: "#pen",
+        component: "pen",
+        property: "color"
+      }
+    ]
+  });
 }
 
 export default registerNetworkSchemas;
diff --git a/src/react-components/2d-hud.js b/src/react-components/2d-hud.js
index 15168e999bd4576f80b0f4242ed60675f4557699..052817a81d6aac00a1b2881df7eb73a5268fc79c 100644
--- a/src/react-components/2d-hud.js
+++ b/src/react-components/2d-hud.js
@@ -4,8 +4,8 @@ import cx from "classnames";
 
 import styles from "../assets/stylesheets/2d-hud.scss";
 
-const TopHUD = ({ muted, frozen, spacebubble, onToggleMute, onToggleFreeze, onToggleSpaceBubble }) => (
-  <div className={cx(styles.container, styles.top)}>
+const TopHUD = ({ muted, frozen, onToggleMute, onToggleFreeze, onSpawnPen }) => (
+  <div className={cx(styles.container, styles.top, styles.unselectable)}>
     <div className={cx("ui-interactive", styles.panel, styles.left)}>
       <div
         className={cx(styles.iconButton, styles.mute, { [styles.active]: muted })}
@@ -19,11 +19,7 @@ const TopHUD = ({ muted, frozen, spacebubble, onToggleMute, onToggleFreeze, onTo
       onClick={onToggleFreeze}
     />
     <div className={cx("ui-interactive", styles.panel, styles.right)}>
-      <div
-        className={cx(styles.iconButton, styles.bubble, { [styles.active]: spacebubble })}
-        title={spacebubble ? "Disable Bubble" : "Enable Bubble"}
-        onClick={onToggleSpaceBubble}
-      />
+      <div className={cx(styles.iconButton, styles.spawn_pen)} title={"Drawing Pen"} onClick={onSpawnPen} />
     </div>
   </div>
 );
@@ -31,24 +27,48 @@ const TopHUD = ({ muted, frozen, spacebubble, onToggleMute, onToggleFreeze, onTo
 TopHUD.propTypes = {
   muted: PropTypes.bool,
   frozen: PropTypes.bool,
-  spacebubble: PropTypes.bool,
   onToggleMute: PropTypes.func,
   onToggleFreeze: PropTypes.func,
-  onToggleSpaceBubble: PropTypes.func
+  onSpawnPen: PropTypes.func
 };
 
-const BottomHUD = ({ onCreateObject }) => (
-  <div className={cx(styles.container, styles.bottom)}>
-    <div
-      className={cx("ui-interactive", styles.iconButton, styles.large, styles.createObject)}
-      title={"Create Object"}
-      onClick={onCreateObject}
-    />
+const BottomHUD = ({ onCreateObject, showPhotoPicker, onMediaPicked }) => (
+  <div className={cx(styles.container, styles.column, styles.bottom, styles.unselectable)}>
+    {showPhotoPicker ? (
+      <div className={cx("ui-interactive", styles.panel, styles.up)}>
+        <input
+          id="media-picker-input"
+          className={cx(styles.hide)}
+          type="file"
+          accept="image/*"
+          multiple
+          onChange={e => {
+            for (const file of e.target.files) {
+              onMediaPicked(file);
+            }
+          }}
+        />
+        <label htmlFor="media-picker-input">
+          <div className={cx(styles.iconButton, styles.mobileMediaPicker)} title={"Pick Media"} />
+        </label>
+      </div>
+    ) : (
+      <div />
+    )}
+    <div>
+      <div
+        className={cx("ui-interactive", styles.iconButton, styles.large, styles.createObject)}
+        title={"Create Object"}
+        onClick={onCreateObject}
+      />
+    </div>
   </div>
 );
 
 BottomHUD.propTypes = {
-  onCreateObject: PropTypes.func
+  onCreateObject: PropTypes.func,
+  showPhotoPicker: PropTypes.bool,
+  onMediaPicked: PropTypes.func
 };
 
 export default { TopHUD, BottomHUD };
diff --git a/src/react-components/auth-dialog.js b/src/react-components/auth-dialog.js
new file mode 100644
index 0000000000000000000000000000000000000000..5c7796863c6521b97edbc0d42571d8bea4b10911
--- /dev/null
+++ b/src/react-components/auth-dialog.js
@@ -0,0 +1,32 @@
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { injectIntl, FormattedMessage } from "react-intl";
+import DialogContainer from "./dialog-container.js";
+
+class AuthDialog extends Component {
+  static propTypes = {
+    intl: PropTypes.object,
+    verifying: PropTypes.bool,
+    authOrigin: PropTypes.string
+  };
+
+  render() {
+    const { authOrigin, verifying } = this.props;
+    const { formatMessage } = this.props.intl;
+    const title = verifying ? formatMessage({ id: "auth.verifying" }) : formatMessage({ id: "auth.verified-title" });
+
+    return (
+      <DialogContainer title={title} {...this.props}>
+        {verifying ? (
+          <FormattedMessage id="auth.verifying" />
+        ) : authOrigin === "spoke" ? (
+          <FormattedMessage id="auth.spoke-verified" values={{ br: <br /> }} />
+        ) : (
+          <FormattedMessage id="auth.verified" />
+        )}
+      </DialogContainer>
+    );
+  }
+}
+
+export default injectIntl(AuthDialog);
diff --git a/src/react-components/create-object-dialog.js b/src/react-components/create-object-dialog.js
index caf31f378b62aaeb5103721a36f182a42a5c8d07..2636230326838dc4bcabbb73e8d38a8674b2f4da 100644
--- a/src/react-components/create-object-dialog.js
+++ b/src/react-components/create-object-dialog.js
@@ -1,11 +1,12 @@
 import React, { Component } from "react";
-import "aframe";
 import PropTypes from "prop-types";
 import giphyLogo from "../assets/images/giphy_logo.png";
 import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { faPaperclip, faTimes } from "@fortawesome/free-solid-svg-icons";
+import { faPaperclip } from "@fortawesome/free-solid-svg-icons/faPaperclip";
+import { faTimes } from "@fortawesome/free-solid-svg-icons/faTimes";
 import styles from "../assets/stylesheets/create-object-dialog.scss";
 import cx from "classnames";
+import DialogContainer from "./dialog-container.js";
 
 const attributionHostnames = {
   "giphy.com": giphyLogo,
@@ -35,8 +36,8 @@ export default class CreateObjectDialog extends Component {
   };
 
   static propTypes = {
-    onCreateObject: PropTypes.func,
-    onCloseDialog: PropTypes.func
+    onCreate: PropTypes.func,
+    onClose: PropTypes.func
   };
 
   componentDidMount() {
@@ -66,12 +67,6 @@ export default class CreateObjectDialog extends Component {
     });
   };
 
-  onCreateClicked = e => {
-    e.preventDefault();
-    this.props.onCreateObject(this.state.file || this.state.url || DEFAULT_OBJECT_URL);
-    this.props.onCloseDialog();
-  };
-
   reset = e => {
     e.preventDefault();
     this.setState({
@@ -82,7 +77,15 @@ export default class CreateObjectDialog extends Component {
     this.fileInput.value = null;
   };
 
+  onCreateClicked = e => {
+    e.preventDefault();
+    this.props.onCreate(this.state.file || this.state.url || DEFAULT_OBJECT_URL);
+    this.props.onClose();
+  };
+
   render() {
+    const { onCreate, onClose, ...other } = this.props; // eslint-disable-line no-unused-vars
+
     const cancelButton = (
       <label className={cx(styles.smallButton, styles.cancelIcon)} onClick={this.reset}>
         <FontAwesomeIcon icon={faTimes} />
@@ -105,34 +108,36 @@ export default class CreateObjectDialog extends Component {
     );
 
     return (
-      <div>
-        {isMobile ? mobileInstructions : desktopInstructions}
-        <form onSubmit={this.onCreateClicked}>
-          <div className={styles.addMediaForm}>
-            <input
-              id={fileInputId}
-              ref={f => (this.fileInput = f)}
-              className={styles.hideFileInput}
-              type="file"
-              onChange={this.onFileChange}
-            />
-            <div className={styles.inputBorder}>
-              {this.state.file ? filenameLabel : urlInput}
-              {this.state.url || this.state.fileName ? cancelButton : uploadButton}
-            </div>
-            <div className={styles.buttons}>
-              <button className={styles.actionButton}>
-                <span>create</span>
-              </button>
-            </div>
-            {this.state.attributionImage ? (
-              <div>
-                <img src={this.state.attributionImage} />
+      <DialogContainer title="Create Object" onClose={onClose} {...other}>
+        <div>
+          {isMobile ? mobileInstructions : desktopInstructions}
+          <form onSubmit={this.onCreateClicked}>
+            <div className={styles.addMediaForm}>
+              <input
+                id={fileInputId}
+                ref={f => (this.fileInput = f)}
+                className={styles.hideFileInput}
+                type="file"
+                onChange={this.onFileChange}
+              />
+              <div className={styles.inputBorder}>
+                {this.state.file ? filenameLabel : urlInput}
+                {this.state.url || this.state.fileName ? cancelButton : uploadButton}
+              </div>
+              <div className={styles.buttons}>
+                <button className={styles.actionButton}>
+                  <span>create</span>
+                </button>
               </div>
-            ) : null}
-          </div>
-        </form>
-      </div>
+              {this.state.attributionImage ? (
+                <div>
+                  <img src={this.state.attributionImage} />
+                </div>
+              ) : null}
+            </div>
+          </form>
+        </div>
+      </DialogContainer>
     );
   }
 }
diff --git a/src/react-components/create-room-dialog.js b/src/react-components/create-room-dialog.js
new file mode 100644
index 0000000000000000000000000000000000000000..756cc9bf43736059999a1a66b2fdfe5d856a4e96
--- /dev/null
+++ b/src/react-components/create-room-dialog.js
@@ -0,0 +1,59 @@
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import DialogContainer from "./dialog-container.js";
+
+const HUB_NAME_PATTERN = "^[A-Za-z0-9-'\":!@#$%^&*(),.?~ ]{4,64}$";
+
+export default class CreateObjectDialog extends Component {
+  static propTypes = {
+    onCustomScene: PropTypes.func,
+    onClose: PropTypes.func
+  };
+
+  state = {
+    customRoomName: "",
+    customSceneUrl: ""
+  };
+
+  render() {
+    const { onCustomScene, onClose, ...other } = this.props;
+    const onCustomSceneClicked = () => {
+      onCustomScene(this.state.customRoomName, this.state.customSceneUrl);
+      onClose();
+    };
+
+    return (
+      <DialogContainer title="Create a Room" onClose={onClose} {...other}>
+        <div>
+          <div>Choose a name and GLTF URL for your room&apos;s scene:</div>
+          <form onSubmit={onCustomSceneClicked}>
+            <div className="custom-scene-form">
+              <input
+                type="text"
+                placeholder="Room name"
+                className="custom-scene-form__link_field"
+                value={this.state.customRoomName}
+                pattern={HUB_NAME_PATTERN}
+                title="Invalid name, limited to 4 to 64 characters and limited symbols."
+                onChange={e => this.setState({ customRoomName: e.target.value })}
+                required
+              />
+              <input
+                type="url"
+                placeholder="URL to Scene GLTF or GLB (Optional)"
+                className="custom-scene-form__link_field"
+                value={this.state.customSceneUrl}
+                onChange={e => this.setState({ customSceneUrl: e.target.value })}
+              />
+              <div className="custom-scene-form__buttons">
+                <button className="custom-scene-form__action-button">
+                  <span>create</span>
+                </button>
+              </div>
+            </div>
+          </form>
+        </div>
+      </DialogContainer>
+    );
+  }
+}
diff --git a/src/react-components/dialog-container.js b/src/react-components/dialog-container.js
new file mode 100644
index 0000000000000000000000000000000000000000..18c37956c7408150d59d2385e251817ad9747cb7
--- /dev/null
+++ b/src/react-components/dialog-container.js
@@ -0,0 +1,57 @@
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+
+export default class DialogContainer extends Component {
+  static propTypes = {
+    title: PropTypes.node,
+    children: PropTypes.node.isRequired,
+    onClose: PropTypes.func
+  };
+
+  constructor(props) {
+    super(props);
+    this.onKeyDown = this.onKeyDown.bind(this);
+    this.onContainerClicked = this.onContainerClicked.bind(this);
+  }
+
+  componentDidMount() {
+    window.addEventListener("keydown", this.onKeyDown);
+  }
+
+  componentWillUnmount() {
+    window.removeEventListener("keydown", this.onKeyDown);
+  }
+
+  onKeyDown(e) {
+    if (e.key === "Escape") {
+      this.props.onClose();
+    }
+  }
+
+  onContainerClicked = e => {
+    if (e.currentTarget === e.target) {
+      this.props.onClose();
+    }
+  };
+
+  render() {
+    return (
+      <div className="dialog-overlay">
+        <div className="dialog" onClick={this.onContainerClicked}>
+          <div className="dialog__box">
+            <div className="dialog__box__contents">
+              {this.props.onClose && (
+                <button className="dialog__box__contents__close" onClick={this.props.onClose}>
+                  <span>×</span>
+                </button>
+              )}
+              <div className="dialog__box__contents__title">{this.props.title}</div>
+              <div className="dialog__box__contents__body">{this.props.children}</div>
+              <div className="dialog__box__contents__button-container" />
+            </div>
+          </div>
+        </div>
+      </div>
+    );
+  }
+}
diff --git a/src/react-components/help-dialog.js b/src/react-components/help-dialog.js
new file mode 100644
index 0000000000000000000000000000000000000000..dc0ba425a3f4e9cd12db05c503280fc1c19ce3d7
--- /dev/null
+++ b/src/react-components/help-dialog.js
@@ -0,0 +1,48 @@
+import React, { Component } from "react";
+import { FormattedMessage } from "react-intl";
+import DialogContainer from "./dialog-container.js";
+
+export default class HelpDialog extends Component {
+  render() {
+    return (
+      <DialogContainer title="Getting Started" {...this.props}>
+        <div className="info-dialog__help">
+          <p>When in a room, other avatars can see and hear you.</p>
+          <p>
+            Use your controller&apos;s action button to teleport from place to place. If it has a trigger, use it to
+            pick up objects.
+          </p>
+          <p style={{ textAlign: "center" }}>
+            In VR, <b>look up</b> to find your menu:
+            <img
+              className="info-dialog__help__hud"
+              src="../assets/images/help-hud.png"
+              srcSet="../assets/images/help-hud@2x.png 2x"
+            />
+          </p>
+          <p>
+            The <b>Mic Toggle</b> mutes your mic.
+          </p>
+          <p>
+            The <b>Pause/Resume Toggle</b> pauses all other avatars. You can then block them from having further
+            interactions with you.
+          </p>
+          <p>
+            The <b>Bubble Toggle</b> hides avatars that enter your personal space.
+          </p>
+          <p className="dialog__box__contents__links">
+            <a target="_blank" rel="noopener noreferrer" href="https://github.com/mozilla/hubs/blob/master/TERMS.md">
+              <FormattedMessage id="profile.terms_of_use" />
+            </a>
+            <a target="_blank" rel="noopener noreferrer" href="https://github.com/mozilla/hubs/blob/master/PRIVACY.md">
+              <FormattedMessage id="profile.privacy_notice" />
+            </a>
+            <a target="_blank" rel="noopener noreferrer" href="/?report">
+              <FormattedMessage id="help.report_issue" />
+            </a>
+          </p>
+        </div>
+      </DialogContainer>
+    );
+  }
+}
diff --git a/src/react-components/home-root.js b/src/react-components/home-root.js
index 80df12ea336bfe0c35fae69ef05e38b95f88fde6..450a4bcf2defd7285080bc9583f855f5ac14fdd7 100644
--- a/src/react-components/home-root.js
+++ b/src/react-components/home-root.js
@@ -8,34 +8,75 @@ import homeVideoWebM from "../assets/video/home.webm";
 import homeVideoMp4 from "../assets/video/home.mp4";
 import classNames from "classnames";
 import { ENVIRONMENT_URLS } from "../assets/environments/environments";
+import { connectToReticulum } from "../utils/phoenix-utils";
 
 import styles from "../assets/stylesheets/index.scss";
 
 import HubCreatePanel from "./hub-create-panel.js";
-import InfoDialog from "./info-dialog.js";
+import AuthDialog from "./auth-dialog.js";
+import ReportDialog from "./report-dialog.js";
+import SlackDialog from "./slack-dialog.js";
+import UpdatesDialog from "./updates-dialog.js";
+import DialogContainer from "./dialog-container.js";
 
 addLocaleData([...en]);
 
 class HomeRoot extends Component {
   static propTypes = {
     intl: PropTypes.object,
-    dialogType: PropTypes.symbol,
+    sceneId: PropTypes.string,
+    authVerify: PropTypes.bool,
+    authTopic: PropTypes.string,
+    authToken: PropTypes.string,
+    authOrigin: PropTypes.string,
+    listSignup: PropTypes.bool,
+    report: PropTypes.bool,
     initialEnvironment: PropTypes.string
   };
 
   state = {
     environments: [],
-    dialogType: null,
+    dialog: null,
     mailingListEmail: "",
     mailingListPrivacy: false
   };
 
   componentDidMount() {
-    this.loadEnvironments();
-    this.setState({ dialogType: this.props.dialogType });
+    this.closeDialog = this.closeDialog.bind(this);
+    if (this.props.authVerify) {
+      this.showAuthDialog(true);
+      this.verifyAuth().then(this.showAuthDialog);
+      return;
+    }
+    if (this.props.sceneId) {
+      this.loadEnvironmentFromScene();
+    } else {
+      this.loadEnvironments();
+    }
     this.loadHomeVideo();
+    if (this.props.listSignup) {
+      this.showUpdatesDialog();
+    } else if (this.props.report) {
+      this.showReportDialog();
+    }
+  }
+
+  async verifyAuth() {
+    const socket = connectToReticulum();
+    const channel = socket.channel(this.props.authTopic);
+    await new Promise((resolve, reject) =>
+      channel
+        .join()
+        .receive("ok", resolve)
+        .receive("error", reject)
+    );
+    channel.push("auth_verified", { token: this.props.authToken });
   }
 
+  showAuthDialog = verifying => {
+    this.setState({ dialog: <AuthDialog verifying={verifying} authOrigin={this.props.authOrigin} /> });
+  };
+
   loadHomeVideo = () => {
     const videoEl = document.querySelector("#background-video");
     videoEl.playbackRate = 0.9;
@@ -54,12 +95,55 @@ class HomeRoot extends Component {
     }
   };
 
-  showDialog = dialogType => {
-    return e => {
-      e.preventDefault();
-      e.stopPropagation();
-      this.setState({ dialogType });
-    };
+  closeDialog() {
+    this.setState({ dialog: null });
+  }
+
+  showSlackDialog() {
+    this.setState({ dialog: <SlackDialog onClose={this.closeDialog} /> });
+  }
+
+  showReportDialog() {
+    this.setState({ dialog: <ReportDialog onClose={this.closeDialog} /> });
+  }
+
+  showUpdatesDialog() {
+    this.setState({
+      dialog: <UpdatesDialog onClose={this.closeDialog} onSubmittedEmail={() => this.showEmailSubmittedDialog()} />
+    });
+  }
+
+  showEmailSubmittedDialog() {
+    this.setState({
+      dialog: (
+        <DialogContainer onClose={this.closeDialog}>
+          Great! Please check your e-mail to confirm your subscription.
+        </DialogContainer>
+      )
+    });
+  }
+
+  loadEnvironmentFromScene = async () => {
+    let sceneUrlBase = "/api/v1/scenes";
+    if (process.env.RETICULUM_SERVER) {
+      sceneUrlBase = `https://${process.env.RETICULUM_SERVER}${sceneUrlBase}`;
+    }
+    const sceneInfoUrl = `${sceneUrlBase}/${this.props.sceneId}`;
+    const resp = await fetch(sceneInfoUrl).then(r => r.json());
+    const scene = resp.scenes[0];
+    // Transform the scene info into a an environment bundle structure.
+    this.setState({
+      environments: [
+        {
+          // Environment loading doesn't check the content-type, so we force a .glb extension here.
+          bundle_url: `${scene.model_url}.glb`,
+          meta: {
+            title: scene.name,
+            images: [{ type: "preview-thumbnail", srcset: scene.screenshot_url }]
+          }
+        }
+      ]
+    });
   };
 
   loadEnvironments = () => {
@@ -77,12 +161,19 @@ class HomeRoot extends Component {
     Promise.all(environmentLoads).then(() => this.setState({ environments }));
   };
 
+  onDialogLinkClicked = trigger => {
+    return e => {
+      e.preventDefault();
+      e.stopPropagation();
+      trigger();
+    };
+  };
+
   render() {
     const mainContentClassNames = classNames({
       [styles.mainContent]: true,
-      [styles.noninteractive]: !!this.state.dialogType
+      [styles.noninteractive]: !!this.state.dialog
     });
-    const dialogTypes = InfoDialog.dialogTypes;
 
     return (
       <IntlProvider locale={lang} messages={messages}>
@@ -141,7 +232,7 @@ class HomeRoot extends Component {
                     className={styles.link}
                     rel="noopener noreferrer"
                     href="#"
-                    onClick={this.showDialog(dialogTypes.slack)}
+                    onClick={this.onDialogLinkClicked(this.showSlackDialog.bind(this))}
                   >
                     <FormattedMessage id="home.join_us" />
                   </a>
@@ -149,7 +240,7 @@ class HomeRoot extends Component {
                     className={styles.link}
                     rel="noopener noreferrer"
                     href="#"
-                    onClick={this.showDialog(dialogTypes.updates)}
+                    onClick={this.onDialogLinkClicked(this.showUpdatesDialog.bind(this))}
                   >
                     <FormattedMessage id="home.get_updates" />
                   </a>
@@ -157,7 +248,7 @@ class HomeRoot extends Component {
                     className={styles.link}
                     rel="noopener noreferrer"
                     href="#"
-                    onClick={this.showDialog(dialogTypes.report)}
+                    onClick={this.onDialogLinkClicked(this.showReportDialog.bind(this))}
                   >
                     <FormattedMessage id="home.report_issue" />
                   </a>
@@ -191,13 +282,7 @@ class HomeRoot extends Component {
             <source src={homeVideoWebM} type="video/webm" />
             <source src={homeVideoMp4} type="video/mp4" />
           </video>
-          {this.state.dialogType && (
-            <InfoDialog
-              dialogType={this.state.dialogType}
-              onCloseDialog={() => this.setState({ dialogType: null })}
-              onSubmittedEmail={() => this.setState({ dialogType: dialogTypes.email_submitted })}
-            />
-          )}
+          {this.state.dialog}
         </div>
       </IntlProvider>
     );
diff --git a/src/react-components/hub-create-panel.js b/src/react-components/hub-create-panel.js
index 1abb749612baa6f8b8104cdd034a4d9b9531536e..a39a6d9b492e56d7179c996d5b940eb910f57e25 100644
--- a/src/react-components/hub-create-panel.js
+++ b/src/react-components/hub-create-panel.js
@@ -6,13 +6,12 @@ import { faAngleLeft } from "@fortawesome/free-solid-svg-icons/faAngleLeft";
 import { faAngleRight } from "@fortawesome/free-solid-svg-icons/faAngleRight";
 import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
 import { resolveURL, extractUrlBase } from "../utils/resolveURL";
-import InfoDialog from "./info-dialog.js";
+import CreateRoomDialog from "./create-room-dialog.js";
 
 import default_scene_preview_thumbnail from "../assets/images/default_thumbnail.png";
 import styles from "../assets/stylesheets/hub-create.scss";
 
 const HUB_NAME_PATTERN = "^[A-Za-z0-9-'\":!@#$%^&*(),.?~ ]{4,64}$";
-const dialogTypes = InfoDialog.dialogTypes;
 
 class HubCreatePanel extends Component {
   static propTypes = {
@@ -178,7 +177,7 @@ class HubCreatePanel extends Component {
                           {environmentTitle}
                         </a>
                       ) : (
-                        <span className={styles.itle}>environmentTitle</span>
+                        <span className={styles.title}>{environmentTitle}</span>
                       )}
                       {environmentAuthor &&
                         environmentAuthor.name &&
@@ -238,9 +237,8 @@ class HubCreatePanel extends Component {
           </div>
         </form>
         {this.state.showCustomSceneDialog && (
-          <InfoDialog
-            dialogType={dialogTypes.custom_scene}
-            onCloseDialog={() => this.setState({ showCustomSceneDialog: false })}
+          <CreateRoomDialog
+            onClose={() => this.setState({ showCustomSceneDialog: false })}
             onCustomScene={(name, url) => {
               this.setState({ showCustomSceneDialog: false, name: name, customSceneUrl: url }, () => this.createHub());
             }}
diff --git a/src/react-components/info-dialog.js b/src/react-components/info-dialog.js
deleted file mode 100644
index 5130e54a3de6e20e68f4b79ba052aa98d993d94e..0000000000000000000000000000000000000000
--- a/src/react-components/info-dialog.js
+++ /dev/null
@@ -1,396 +0,0 @@
-import React, { Component } from "react";
-import copy from "copy-to-clipboard";
-import PropTypes from "prop-types";
-import { FormattedMessage } from "react-intl";
-import formurlencoded from "form-urlencoded";
-import LinkDialog from "./link-dialog.js";
-import CreateObjectDialog from "./create-object-dialog.js";
-const HUB_NAME_PATTERN = "^[A-Za-z0-9-'\":!@#$%^&*(),.?~ ]{4,64}$";
-
-// TODO i18n
-
-class InfoDialog extends Component {
-  static dialogTypes = {
-    slack: Symbol("slack"),
-    email_submitted: Symbol("email_submitted"),
-    invite: Symbol("invite"),
-    safari: Symbol("safari"),
-    updates: Symbol("updates"),
-    report: Symbol("report"),
-    help: Symbol("help"),
-    link: Symbol("link"),
-    webvr_recommend: Symbol("webvr_recommend"),
-    create_object: Symbol("create_object"),
-    custom_scene: Symbol("custom_scene")
-  };
-  static propTypes = {
-    dialogType: PropTypes.oneOf(Object.values(InfoDialog.dialogTypes)),
-    onCloseDialog: PropTypes.func,
-    onSubmittedEmail: PropTypes.func,
-    onCreateObject: PropTypes.func,
-    onCustomScene: PropTypes.func,
-    linkCode: PropTypes.string
-  };
-
-  constructor(props) {
-    super(props);
-
-    const loc = document.location;
-    this.shareLink = `${loc.protocol}//${loc.host}${loc.pathname}`;
-    this.onKeyDown = this.onKeyDown.bind(this);
-    this.onContainerClicked = this.onContainerClicked.bind(this);
-  }
-
-  componentDidMount() {
-    window.addEventListener("keydown", this.onKeyDown);
-  }
-
-  componentWillUnmount() {
-    window.removeEventListener("keydown", this.onKeyDown);
-  }
-
-  onKeyDown(e) {
-    if (e.key === "Escape") {
-      this.props.onCloseDialog();
-    }
-  }
-
-  onContainerClicked = e => {
-    if (e.currentTarget === e.target) {
-      this.props.onCloseDialog();
-    }
-  };
-
-  onCustomSceneClicked = () => {
-    this.props.onCustomScene(this.state.customRoomName, this.state.customSceneUrl);
-    this.props.onCloseDialog();
-  };
-
-  shareLinkClicked = () => {
-    navigator.share({
-      title: document.title,
-      url: this.shareLink
-    });
-  };
-
-  copyLinkClicked = link => {
-    copy(link);
-    this.setState({ copyLinkButtonText: "copied!" });
-  };
-
-  state = {
-    mailingListEmail: "",
-    mailingListPrivacy: false,
-    copyLinkButtonText: "copy",
-    createObjectUrl: "",
-    customRoomName: "",
-    customSceneUrl: ""
-  };
-
-  signUpForMailingList = async e => {
-    e.preventDefault();
-    e.stopPropagation();
-    if (!this.state.mailingListPrivacy) return;
-
-    const url = "https://www.mozilla.org/en-US/newsletter/";
-
-    const payload = {
-      email: this.state.mailingListEmail,
-      newsletters: "hubs",
-      privacy: true,
-      fmt: "H",
-      source_url: document.location.href
-    };
-
-    await fetch(url, {
-      body: formurlencoded(payload),
-      method: "POST",
-      headers: { "content-type": "application/x-www-form-urlencoded" }
-    }).then(this.props.onSubmittedEmail);
-  };
-
-  render() {
-    if (!this.props.dialogType) {
-      return <div />;
-    }
-
-    let dialogTitle = null;
-    let dialogBody = null;
-
-    switch (this.props.dialogType) {
-      // TODO i18n, FormattedMessage doesn't play nicely with links
-      case InfoDialog.dialogTypes.slack:
-        dialogTitle = "Get in Touch";
-        dialogBody = (
-          <span>
-            <p>Want to join the conversation?</p>
-            <p>
-              Join us on the{" "}
-              <a href="https://webvr-slack.herokuapp.com/" target="_blank" rel="noopener noreferrer">
-                WebVR Slack
-              </a>{" "}
-              in the{" "}
-              <a href="https://webvr.slack.com/messages/social" target="_blank" rel="noopener noreferrer">
-                #social
-              </a>{" "}
-              channel.<br />
-              VR meetups every Friday at noon PDT!
-            </p>
-            <p>
-              Or, tweet at{" "}
-              <a href="https://twitter.com/mozillareality" target="_blank" rel="noopener noreferrer">
-                @mozillareality
-              </a>{" "}
-              on Twitter.
-            </p>
-          </span>
-        );
-        break;
-      case InfoDialog.dialogTypes.email_submitted:
-        dialogTitle = "";
-        dialogBody = "Great! Please check your e-mail to confirm your subscription.";
-        break;
-      case InfoDialog.dialogTypes.invite:
-        dialogTitle = "Invite Others";
-        dialogBody = (
-          <div>
-            <div>Just share the link and they&apos;ll join you:</div>
-            <div className="invite-form">
-              <input
-                type="text"
-                readOnly
-                onFocus={e => e.target.select()}
-                value={this.shareLink}
-                className="invite-form__link_field"
-              />
-              <div className="invite-form__buttons">
-                {navigator.share && (
-                  <button className="invite-form__action-button" onClick={this.shareLinkClicked}>
-                    <span>share</span>
-                  </button>
-                )}
-                <button
-                  className="invite-form__action-button"
-                  onClick={this.copyLinkClicked.bind(this, this.shareLink)}
-                >
-                  <span>{this.state.copyLinkButtonText}</span>
-                </button>
-              </div>
-            </div>
-          </div>
-        );
-        break;
-      case InfoDialog.dialogTypes.safari:
-        dialogTitle = "Open in Safari";
-        dialogBody = (
-          <div>
-            <div>Hubs does not support your current browser on iOS. Copy and paste this link directly in Safari.</div>
-            <div className="invite-form">
-              <input
-                type="text"
-                readOnly
-                onFocus={e => e.target.select()}
-                value={document.location}
-                className="invite-form__link_field"
-              />
-              <div className="invite-form__buttons">
-                <button
-                  className="invite-form__action-button"
-                  onClick={this.copyLinkClicked.bind(this, document.location)}
-                >
-                  <span>{this.state.copyLinkButtonText}</span>
-                </button>
-              </div>
-            </div>
-          </div>
-        );
-        break;
-      case InfoDialog.dialogTypes.create_object:
-        dialogTitle = "Create Object";
-        dialogBody = (
-          <CreateObjectDialog onCreateObject={this.props.onCreateObject} onCloseDialog={this.props.onCloseDialog} />
-        );
-        break;
-      case InfoDialog.dialogTypes.custom_scene:
-        dialogTitle = "Create a Room";
-        dialogBody = (
-          <div>
-            <div>Choose a name and GLTF URL for your room&apos;s scene:</div>
-            <form onSubmit={this.onCustomSceneClicked}>
-              <div className="custom-scene-form">
-                <input
-                  type="text"
-                  placeholder="Room name"
-                  className="custom-scene-form__link_field"
-                  value={this.state.customRoomName}
-                  pattern={HUB_NAME_PATTERN}
-                  title="Invalid name, limited to 4 to 64 characters and limited symbols."
-                  onChange={e => this.setState({ customRoomName: e.target.value })}
-                  required
-                />
-                <input
-                  type="url"
-                  placeholder="URL to Scene GLTF or GLB (Optional)"
-                  className="custom-scene-form__link_field"
-                  value={this.state.customSceneUrl}
-                  onChange={e => this.setState({ customSceneUrl: e.target.value })}
-                />
-                <div className="custom-scene-form__buttons">
-                  <button className="custom-scene-form__action-button">
-                    <span>create</span>
-                  </button>
-                </div>
-              </div>
-            </form>
-          </div>
-        );
-        break;
-      case InfoDialog.dialogTypes.updates:
-        dialogTitle = "";
-        dialogBody = (
-          <span>
-            <p>Sign up to get updates about new features in Hubs.</p>
-            <form onSubmit={this.signUpForMailingList}>
-              <div className="mailing-list-form">
-                <input
-                  type="email"
-                  value={this.state.mailingListEmail}
-                  onChange={e => this.setState({ mailingListEmail: e.target.value })}
-                  className="mailing-list-form__email_field"
-                  required
-                  placeholder="Your email here"
-                />
-                <label className="mailing-list-form__privacy">
-                  <input
-                    className="mailing-list-form__privacy_checkbox"
-                    type="checkbox"
-                    required
-                    value={this.state.mailingListPrivacy}
-                    onChange={e => this.setState({ mailingListPrivacy: e.target.checked })}
-                  />
-                  <span className="mailing-list-form__privacy_label">
-                    <FormattedMessage id="mailing_list.privacy_label" />{" "}
-                    <a target="_blank" rel="noopener noreferrer" href="https://www.mozilla.org/en-US/privacy/">
-                      <FormattedMessage id="mailing_list.privacy_link" />
-                    </a>
-                  </span>
-                </label>
-                <input className="mailing-list-form__submit" type="submit" value="Sign Up Now" />
-              </div>
-            </form>
-          </span>
-        );
-        break;
-      case InfoDialog.dialogTypes.report:
-        dialogTitle = "Report an Issue";
-        dialogBody = (
-          <span>
-            <p>Need to report a problem?</p>
-            <p>
-              You can file a{" "}
-              <a href="https://github.com/mozilla/hubs/issues" target="_blank" rel="noopener noreferrer">
-                GitHub Issue
-              </a>{" "}
-              or e-mail us for support at <a href="mailto:hubs@mozilla.com">hubs@mozilla.com</a>.
-            </p>
-            <p>
-              You can also find us in{" "}
-              <a href="https://webvr.slack.com/messages/social" target="_blank" rel="noopener noreferrer">
-                #social
-              </a>{" "}
-              on the{" "}
-              <a href="https://webvr-slack.herokuapp.com/" target="_blank" rel="noopener noreferrer">
-                WebVR Slack
-              </a>.
-            </p>
-          </span>
-        );
-        break;
-      case InfoDialog.dialogTypes.help:
-        dialogTitle = "Getting Started";
-        dialogBody = (
-          <div className="info-dialog__help">
-            <p>When in a room, other avatars can see and hear you.</p>
-            <p>
-              Use your controller&apos;s action button to teleport from place to place. If it has a trigger, use it to
-              pick up objects.
-            </p>
-            <p style={{ textAlign: "center" }}>
-              In VR, <b>look up</b> to find your menu:
-              <img
-                className="info-dialog__help__hud"
-                src="../assets/images/help-hud.png"
-                srcSet="../assets/images/help-hud@2x.png 2x"
-              />
-            </p>
-            <p>
-              The <b>Mic Toggle</b> mutes your mic.
-            </p>
-            <p>
-              The <b>Pause/Resume Toggle</b> pauses all other avatars. You can then block them from having further
-              interactions with you.
-            </p>
-            <p>
-              The <b>Bubble Toggle</b> hides avatars that enter your personal space.
-            </p>
-            <p className="dialog__box__contents__links">
-              <a target="_blank" rel="noopener noreferrer" href="https://github.com/mozilla/hubs/blob/master/TERMS.md">
-                <FormattedMessage id="profile.terms_of_use" />
-              </a>
-              <a
-                target="_blank"
-                rel="noopener noreferrer"
-                href="https://github.com/mozilla/hubs/blob/master/PRIVACY.md"
-              >
-                <FormattedMessage id="profile.privacy_notice" />
-              </a>
-              <a target="_blank" rel="noopener noreferrer" href="/?report">
-                <FormattedMessage id="help.report_issue" />
-              </a>
-            </p>
-          </div>
-        );
-        break;
-      case InfoDialog.dialogTypes.webvr_recommend:
-        dialogTitle = "Enter in VR";
-        dialogBody = (
-          <div>
-            <p>To enter Hubs with Oculus or SteamVR, you can use Firefox.</p>
-            <a className="info-dialog--action-button" href="https://www.mozilla.org/firefox">
-              Download Firefox
-            </a>
-            <p style={{ fontSize: "0.8em" }}>
-              For a full list of browsers with experimental VR support, visit{" "}
-              <a href="https://webvr.rocks" target="_blank" rel="noopener noreferrer">
-                WebVR Rocks
-              </a>.
-            </p>
-          </div>
-        );
-        break;
-      case InfoDialog.dialogTypes.link:
-        dialogTitle = "Open on Headset";
-        dialogBody = <LinkDialog linkCode={this.props.linkCode} />;
-        break;
-    }
-
-    return (
-      <div className="dialog-overlay">
-        <div className="dialog" onClick={this.onContainerClicked}>
-          <div className="dialog__box">
-            <div className="dialog__box__contents">
-              <button className="dialog__box__contents__close" onClick={this.props.onCloseDialog}>
-                <span>×</span>
-              </button>
-              <div className="dialog__box__contents__title">{dialogTitle}</div>
-              <div className="dialog__box__contents__body">{dialogBody}</div>
-              <div className="dialog__box__contents__button-container" />
-            </div>
-          </div>
-        </div>
-      </div>
-    );
-  }
-}
-
-export default InfoDialog;
diff --git a/src/react-components/invite-dialog.js b/src/react-components/invite-dialog.js
new file mode 100644
index 0000000000000000000000000000000000000000..e01fc1282cf835e8e5a65363b3dfc6ee0f4e891f
--- /dev/null
+++ b/src/react-components/invite-dialog.js
@@ -0,0 +1,56 @@
+import React, { Component } from "react";
+import copy from "copy-to-clipboard";
+import DialogContainer from "./dialog-container.js";
+
+export default class InviteDialog extends Component {
+  state = {
+    copyLinkButtonText: "copy"
+  };
+
+  constructor(props) {
+    super(props);
+    const loc = document.location;
+    this.shareLink = `${loc.protocol}//${loc.host}${loc.pathname}`;
+  }
+
+  copyLinkClicked = link => {
+    copy(link);
+    this.setState({ copyLinkButtonText: "copied!" });
+  };
+
+  shareLinkClicked = () => {
+    navigator.share({
+      title: document.title,
+      url: this.shareLink
+    });
+  };
+
+  render() {
+    return (
+      <DialogContainer title="Invite Others" {...this.props}>
+        <div>
+          <div>Just share the link and they&apos;ll join you:</div>
+          <div className="invite-form">
+            <input
+              type="text"
+              readOnly
+              onFocus={e => e.target.select()}
+              value={this.shareLink}
+              className="invite-form__link_field"
+            />
+            <div className="invite-form__buttons">
+              {navigator.share && (
+                <button className="invite-form__action-button" onClick={this.shareLinkClicked}>
+                  <span>share</span>
+                </button>
+              )}
+              <button className="invite-form__action-button" onClick={this.copyLinkClicked.bind(this, this.shareLink)}>
+                <span>{this.state.copyLinkButtonText}</span>
+              </button>
+            </div>
+          </div>
+        </div>
+      </DialogContainer>
+    );
+  }
+}
diff --git a/src/react-components/link-dialog.js b/src/react-components/link-dialog.js
index 80ab360d50d54da5738a9c3c771b090b0d4bc9fa..456090494e4cc9e00799d697530adbe460ae4bd2 100644
--- a/src/react-components/link-dialog.js
+++ b/src/react-components/link-dialog.js
@@ -2,53 +2,57 @@ import React, { Component } from "react";
 import PropTypes from "prop-types";
 import classNames from "classnames";
 import { FormattedMessage } from "react-intl";
+import DialogContainer from "./dialog-container.js";
 
 import styles from "../assets/stylesheets/link-dialog.scss";
 
-class LinkDialog extends Component {
+export default class LinkDialog extends Component {
   static propTypes = {
     linkCode: PropTypes.string
   };
 
   render() {
-    if (!this.props.linkCode) {
+    const { linkCode, ...other } = this.props;
+    if (!linkCode) {
       return (
-        <div>
-          <div className={classNames("loading-panel", styles.codeLoadingPanel)}>
-            <div className="loader-wrap">
-              <div className="loader">
-                <div className="loader-center" />
+        <DialogContainer title="Open on Headset" {...other}>
+          <div>
+            <div className={classNames("loading-panel", styles.codeLoadingPanel)}>
+              <div className="loader-wrap">
+                <div className="loader">
+                  <div className="loader-center" />
+                </div>
               </div>
             </div>
           </div>
-        </div>
+        </DialogContainer>
       );
     }
 
     return (
-      <div>
-        <div>
-          <FormattedMessage id="link.in_your_browser" />
-        </div>
-        <a href="https://hub.link" className={styles.domain} target="_blank" rel="noopener noreferrer">
-          hub.link
-        </a>
+      <DialogContainer title="Open on Headset" {...other}>
         <div>
-          <FormattedMessage id="link.enter_code" />
-        </div>
-        <div className={styles.code}>
-          {this.props.linkCode.split("").map((d, i) => (
-            <span className={styles.digit} key={`link_code_${i}`}>
-              {d}
-            </span>
-          ))}
-        </div>
-        <div className={styles.keepOpen}>
-          <FormattedMessage id="link.do_not_close" />
+          <div>
+            <FormattedMessage id="link.in_your_browser" />
+          </div>
+          <a href="https://hub.link" className={styles.domain} target="_blank" rel="noopener noreferrer">
+            hub.link
+          </a>
+          <div>
+            <FormattedMessage id="link.enter_code" />
+          </div>
+          <div className={styles.code}>
+            {linkCode.split("").map((d, i) => (
+              <span className={styles.digit} key={`link_code_${i}`}>
+                {d}
+              </span>
+            ))}
+          </div>
+          <div className={styles.keepOpen}>
+            <FormattedMessage id="link.do_not_close" />
+          </div>
         </div>
-      </div>
+      </DialogContainer>
     );
   }
 }
-
-export default LinkDialog;
diff --git a/src/react-components/report-dialog.js b/src/react-components/report-dialog.js
new file mode 100644
index 0000000000000000000000000000000000000000..36072eed617957090fc2942a5f17047b3ea533ba
--- /dev/null
+++ b/src/react-components/report-dialog.js
@@ -0,0 +1,31 @@
+import React, { Component } from "react";
+import DialogContainer from "./dialog-container.js";
+
+export default class ReportDialog extends Component {
+  render() {
+    return (
+      <DialogContainer title="Report an Issue" {...this.props}>
+        <span>
+          <p>Need to report a problem?</p>
+          <p>
+            You can file a{" "}
+            <a href="https://github.com/mozilla/hubs/issues" target="_blank" rel="noopener noreferrer">
+              GitHub Issue
+            </a>{" "}
+            or e-mail us for support at <a href="mailto:hubs@mozilla.com">hubs@mozilla.com</a>.
+          </p>
+          <p>
+            You can also find us in{" "}
+            <a href="https://webvr.slack.com/messages/social" target="_blank" rel="noopener noreferrer">
+              #social
+            </a>{" "}
+            on the{" "}
+            <a href="https://webvr-slack.herokuapp.com/" target="_blank" rel="noopener noreferrer">
+              WebVR Slack
+            </a>.
+          </p>
+        </span>
+      </DialogContainer>
+    );
+  }
+}
diff --git a/src/react-components/safari-dialog.js b/src/react-components/safari-dialog.js
new file mode 100644
index 0000000000000000000000000000000000000000..de96bfbf4545e6496e97a3e0dab81593adad7519
--- /dev/null
+++ b/src/react-components/safari-dialog.js
@@ -0,0 +1,39 @@
+import React, { Component } from "react";
+import copy from "copy-to-clipboard";
+import DialogContainer from "./dialog-container.js";
+
+export default class SafariDialog extends Component {
+  state = {
+    copyLinkButtonText: "copy"
+  };
+
+  copyLinkClicked = link => {
+    copy(link);
+    this.setState({ copyLinkButtonText: "copied!" });
+  };
+
+  render() {
+    const onCopyClicked = this.copyLinkClicked.bind(this, document.location);
+    return (
+      <DialogContainer title="Open in Safari" {...this.props}>
+        <div>
+          <div>Hubs does not support your current browser on iOS. Copy and paste this link directly in Safari.</div>
+          <div className="invite-form">
+            <input
+              type="text"
+              readOnly
+              onFocus={e => e.target.select()}
+              value={document.location}
+              className="invite-form__link_field"
+            />
+            <div className="invite-form__buttons">
+              <button className="invite-form__action-button" onClick={onCopyClicked}>
+                <span>{this.state.copyLinkButtonText}</span>
+              </button>
+            </div>
+          </div>
+        </div>
+      </DialogContainer>
+    );
+  }
+}
diff --git a/src/react-components/slack-dialog.js b/src/react-components/slack-dialog.js
new file mode 100644
index 0000000000000000000000000000000000000000..5a024855ba2a206ebcc4a8a80e92e1460daa4b7a
--- /dev/null
+++ b/src/react-components/slack-dialog.js
@@ -0,0 +1,33 @@
+import React, { Component } from "react";
+import DialogContainer from "./dialog-container.js";
+
+export default class SlackDialog extends Component {
+  render() {
+    return (
+      <DialogContainer title="Get in Touch" {...this.props}>
+        <span>
+          <p>Want to join the conversation?</p>
+          <p>
+            Join us on the{" "}
+            <a href="https://webvr-slack.herokuapp.com/" target="_blank" rel="noopener noreferrer">
+              WebVR Slack
+            </a>{" "}
+            in the{" "}
+            <a href="https://webvr.slack.com/messages/social" target="_blank" rel="noopener noreferrer">
+              #social
+            </a>{" "}
+            channel.<br />
+            VR meetups every Friday at noon PDT!
+          </p>
+          <p>
+            Or, tweet at{" "}
+            <a href="https://twitter.com/mozillareality" target="_blank" rel="noopener noreferrer">
+              @mozillareality
+            </a>{" "}
+            on Twitter.
+          </p>
+        </span>
+      </DialogContainer>
+    );
+  }
+}
diff --git a/src/react-components/ui-root.js b/src/react-components/ui-root.js
index 36b5434714a0bca8af2fc7dc4f2f38d094952f3e..43ec3dba9dab07a3b127b8275fce5355bc4808e6 100644
--- a/src/react-components/ui-root.js
+++ b/src/react-components/ui-root.js
@@ -20,7 +20,12 @@ import {
 } from "./entry-buttons.js";
 import { ProfileInfoHeader } from "./profile-info-header.js";
 import ProfileEntryPanel from "./profile-entry-panel";
-import InfoDialog from "./info-dialog.js";
+import HelpDialog from "./help-dialog.js";
+import SafariDialog from "./safari-dialog.js";
+import WebVRRecommendDialog from "./webvr-recommend-dialog.js";
+import InviteDialog from "./invite-dialog.js";
+import LinkDialog from "./link-dialog.js";
+import CreateObjectDialog from "./create-object-dialog.js";
 import TwoDHUD from "./2d-hud";
 import { faUsers } from "@fortawesome/free-solid-svg-icons/faUsers";
 
@@ -79,7 +84,7 @@ class UIRoot extends Component {
   state = {
     entryStep: ENTRY_STEPS.start,
     enterInVR: false,
-    infoDialogType: null,
+    dialog: null,
     linkCode: null,
     linkCodeCancel: null,
 
@@ -160,6 +165,10 @@ class UIRoot extends Component {
     this.props.scene.emit("action_space_bubble");
   };
 
+  spawnPen = () => {
+    this.props.scene.emit("spawn_pen");
+  };
+
   handleForcedVREntryType = () => {
     if (!this.props.forcedVREntryType) return;
 
@@ -264,15 +273,11 @@ class UIRoot extends Component {
     await this.performDirectEntryFlow(false);
   };
 
-  linkSafari = async () => {
-    this.setState({ infoDialogType: InfoDialog.dialogTypes.safari });
-  };
-
   enterVR = async () => {
     if (this.props.availableVREntryTypes.generic !== VR_DEVICE_AVAILABILITY.maybe) {
       await this.performDirectEntryFlow(true);
     } else {
-      this.setState({ infoDialogType: InfoDialog.dialogTypes.webvr_recommend });
+      this.showWebVRRecommendDialog();
     }
   };
 
@@ -512,24 +517,49 @@ class UIRoot extends Component {
   };
 
   attemptLink = async () => {
-    this.setState({ infoDialogType: InfoDialog.dialogTypes.link });
+    this.showLinkDialog();
     const { code, cancel, onFinished } = await this.props.linkChannel.generateCode();
     this.setState({ linkCode: code, linkCodeCancel: cancel });
-    onFinished.then(this.handleCloseDialog);
+    this.showLinkDialog();
+    onFinished.then(this.closeDialog);
   };
 
-  handleCloseDialog = async () => {
+  closeDialog = async () => {
     if (this.state.linkCodeCancel) {
       this.state.linkCodeCancel();
     }
 
-    this.setState({ infoDialogType: null, linkCode: null, linkCodeCancel: null });
+    this.setState({ dialog: null, linkCode: null, linkCodeCancel: null });
   };
 
-  handleCreateObject = url => {
-    this.props.scene.emit("add_media", url);
+  createObject = media => {
+    this.props.scene.emit("add_media", media);
   };
 
+  showHelpDialog() {
+    this.setState({ dialog: <HelpDialog onClose={this.closeDialog} /> });
+  }
+
+  showSafariDialog() {
+    this.setState({ dialog: <SafariDialog onClose={this.closeDialog} /> });
+  }
+
+  showInviteDialog() {
+    this.setState({ dialog: <InviteDialog onClose={this.closeDialog} /> });
+  }
+
+  showCreateObjectDialog() {
+    this.setState({ dialog: <CreateObjectDialog onCreate={this.createObject} onClose={this.closeDialog} /> });
+  }
+
+  showLinkDialog() {
+    this.setState({ dialog: <LinkDialog linkCode={this.state.linkCode} onClose={this.closeDialog} /> });
+  }
+
+  showWebVRRecommendDialog() {
+    this.setState({ dialog: <WebVRRecommendDialog onClose={this.closeDialog} /> });
+  }
+
   render() {
     if (this.state.exited || this.props.roomUnavailableReason || this.props.platformUnsupportedReason) {
       let subtitle = null;
@@ -647,7 +677,7 @@ class UIRoot extends Component {
               <TwoDEntryButton onClick={this.enter2D} />
             )}
             {this.props.availableVREntryTypes.safari === VR_DEVICE_AVAILABILITY.maybe && (
-              <SafariEntryButton onClick={this.linkSafari} />
+              <SafariEntryButton onClick={this.showSafariDialog} />
             )}
             {this.props.availableVREntryTypes.generic !== VR_DEVICE_AVAILABILITY.no && (
               <GenericEntryButton onClick={this.enterVR} />
@@ -669,10 +699,7 @@ class UIRoot extends Component {
               </div>
             )}
             {screenSharingCheckbox}
-            <button
-              className={entryStyles.inviteButton}
-              onClick={() => this.setState({ infoDialogType: InfoDialog.dialogTypes.invite })}
-            >
+            <button className={entryStyles.inviteButton} onClick={() => this.showInviteDialog()}>
               <FormattedMessage id="entry.invite-others" />
             </button>
           </div>
@@ -828,7 +855,7 @@ class UIRoot extends Component {
         <ProfileInfoHeader
           name={this.props.store.state.profile.displayName}
           onClickName={() => this.setState({ showProfileEntry: true })}
-          onClickHelp={() => this.setState({ infoDialogType: InfoDialog.dialogTypes.help })}
+          onClickHelp={() => this.showHelpDialog()}
         />
         {entryPanel}
         {micPanel}
@@ -836,7 +863,7 @@ class UIRoot extends Component {
       </div>
     );
 
-    const dialogBoxClassNames = classNames({ "ui-interactive": !this.state.infoDialogType, "ui-dialog-box": true });
+    const dialogBoxClassNames = classNames({ "ui-interactive": !this.state.dialog, "ui-dialog-box": true });
 
     const dialogBoxContentsClassNames = classNames({
       "ui-dialog-box-contents": true,
@@ -846,19 +873,10 @@ class UIRoot extends Component {
     return (
       <IntlProvider locale={lang} messages={messages}>
         <div className="ui">
-          <InfoDialog
-            dialogType={this.state.infoDialogType}
-            linkCode={this.state.linkCode}
-            onSubmittedEmail={() => this.setState({ infoDialogType: InfoDialog.dialogTypes.email_submitted })}
-            onCloseDialog={this.handleCloseDialog}
-            onCreateObject={this.handleCreateObject}
-          />
+          {this.state.dialog}
 
           {this.state.entryStep === ENTRY_STEPS.finished && (
-            <button
-              onClick={() => this.setState({ infoDialogType: InfoDialog.dialogTypes.help })}
-              className="ui__help-icon"
-            >
+            <button onClick={() => this.showHelpDialog()} className="ui__help-icon">
               <i className="ui__help-icon__icon">
                 <FontAwesomeIcon icon={faQuestion} />
               </i>
@@ -892,11 +910,12 @@ class UIRoot extends Component {
                 onToggleMute={this.toggleMute}
                 onToggleFreeze={this.toggleFreeze}
                 onToggleSpaceBubble={this.toggleSpaceBubble}
+                onSpawnPen={this.spawnPen}
               />
               {!this.props.availableVREntryTypes.isInHMD &&
                 this.props.occupantCount <= 1 && (
                   <div className={styles.nagButton}>
-                    <button onClick={() => this.setState({ infoDialogType: InfoDialog.dialogTypes.invite })}>
+                    <button onClick={() => this.showInviteDialog()}>
                       <FormattedMessage id="entry.invite-others-nag" />
                     </button>
                   </div>
@@ -909,7 +928,9 @@ class UIRoot extends Component {
                 </div>
               )}
               <TwoDHUD.BottomHUD
-                onCreateObject={() => this.setState({ infoDialogType: InfoDialog.dialogTypes.create_object })}
+                onCreateObject={() => this.showCreateObjectDialog()}
+                showPhotoPicker={AFRAME.utils.device.isMobile()}
+                onMediaPicked={this.createObject}
               />
             </div>
           ) : null}
diff --git a/src/react-components/updates-dialog.js b/src/react-components/updates-dialog.js
new file mode 100644
index 0000000000000000000000000000000000000000..71d732c2404a366a7bb5e858ba0609bc6d241473
--- /dev/null
+++ b/src/react-components/updates-dialog.js
@@ -0,0 +1,77 @@
+import React, { Component } from "react";
+import { FormattedMessage } from "react-intl";
+import PropTypes from "prop-types";
+import formurlencoded from "form-urlencoded";
+import DialogContainer from "./dialog-container.js";
+
+export default class UpdatesDialog extends Component {
+  static propTypes = {
+    onSubmittedEmail: PropTypes.func
+  };
+
+  state = {
+    mailingListEmail: "",
+    mailingListPrivacy: false
+  };
+
+  render() {
+    const { onSubmittedEmail, ...other } = this.props;
+    const signUpForMailingList = async e => {
+      e.preventDefault();
+      e.stopPropagation();
+      if (!this.state.mailingListPrivacy) return;
+
+      const url = "https://www.mozilla.org/en-US/newsletter/";
+
+      const payload = {
+        email: this.state.mailingListEmail,
+        newsletters: "hubs",
+        privacy: true,
+        fmt: "H",
+        source_url: document.location.href
+      };
+
+      await fetch(url, {
+        body: formurlencoded(payload),
+        method: "POST",
+        headers: { "content-type": "application/x-www-form-urlencoded" }
+      }).then(onSubmittedEmail);
+    };
+
+    return (
+      <DialogContainer {...other}>
+        <span>
+          <p>Sign up to get updates about new features in Hubs.</p>
+          <form onSubmit={signUpForMailingList}>
+            <div className="mailing-list-form">
+              <input
+                type="email"
+                value={this.state.mailingListEmail}
+                onChange={e => this.setState({ mailingListEmail: e.target.value })}
+                className="mailing-list-form__email_field"
+                required
+                placeholder="Your email here"
+              />
+              <label className="mailing-list-form__privacy">
+                <input
+                  className="mailing-list-form__privacy_checkbox"
+                  type="checkbox"
+                  required
+                  value={this.state.mailingListPrivacy}
+                  onChange={e => this.setState({ mailingListPrivacy: e.target.checked })}
+                />
+                <span className="mailing-list-form__privacy_label">
+                  <FormattedMessage id="mailing_list.privacy_label" />{" "}
+                  <a target="_blank" rel="noopener noreferrer" href="https://www.mozilla.org/en-US/privacy/">
+                    <FormattedMessage id="mailing_list.privacy_link" />
+                  </a>
+                </span>
+              </label>
+              <input className="mailing-list-form__submit" type="submit" value="Sign Up Now" />
+            </div>
+          </form>
+        </span>
+      </DialogContainer>
+    );
+  }
+}
diff --git a/src/react-components/webvr-recommend-dialog.js b/src/react-components/webvr-recommend-dialog.js
new file mode 100644
index 0000000000000000000000000000000000000000..cd7d9434498a16ac219686d8fb5c264a962abd9e
--- /dev/null
+++ b/src/react-components/webvr-recommend-dialog.js
@@ -0,0 +1,23 @@
+import React, { Component } from "react";
+import DialogContainer from "./dialog-container.js";
+
+export default class WebVRRecommendDialog extends Component {
+  render() {
+    return (
+      <DialogContainer title="Enter in VR" {...this.props}>
+        <div>
+          <p>To enter Hubs with Oculus or SteamVR, you can use Firefox.</p>
+          <a className="info-dialog--action-button" href="https://www.mozilla.org/firefox">
+            Download Firefox
+          </a>
+          <p style={{ fontSize: "0.8em" }}>
+            For a full list of browsers with experimental VR support, visit{" "}
+            <a href="https://webvr.rocks" target="_blank" rel="noopener noreferrer">
+              WebVR Rocks
+            </a>.
+          </p>
+        </div>
+      </DialogContainer>
+    );
+  }
+}
diff --git a/src/systems/tunnel-effect.js b/src/systems/tunnel-effect.js
new file mode 100644
index 0000000000000000000000000000000000000000..8375964060a8c85977fe999ee4f73086f74b75d8
--- /dev/null
+++ b/src/systems/tunnel-effect.js
@@ -0,0 +1,163 @@
+import "../utils/postprocessing/EffectComposer";
+import "../utils/postprocessing/RenderPass";
+import "../utils/postprocessing/ShaderPass";
+import "../utils/postprocessing/MaskPass";
+import "../utils/shaders/CopyShader";
+import "../utils/shaders/VignetteShader";
+import qsTruthy from "../utils/qs_truthy";
+
+const disabledByQueryString = qsTruthy("disableTunnel");
+const CLAMP_SPEED = 0.01;
+const CLAMP_RADIUS = 0.001;
+const NO_TUNNEL_RADIUS = 10.0;
+const NO_TUNNEL_SOFTNESS = 0.0;
+
+function lerp(start, end, t) {
+  return (1 - t) * start + t * end;
+}
+
+function f(t) {
+  const x = t - 1;
+  return 1 + x * x * x * x * x;
+}
+
+AFRAME.registerSystem("tunneleffect", {
+  schema: {
+    targetComponent: { type: "string", default: "character-controller" },
+    radius: { type: "number", default: 1.0, min: 0.25 },
+    minRadius: { type: "number", default: 0.25, min: 0.1 },
+    maxSpeed: { type: "number", default: 0.5, min: 0.1 },
+    softest: { type: "number", default: 0.1, min: 0.0 },
+    opacity: { type: "number", default: 1, min: 0.0 }
+  },
+
+  init: function() {
+    this.scene = this.el;
+    this.isMoving = false;
+    this.isVR = false;
+    this.dt = 0;
+    this.isPostProcessingReady = false;
+    this.characterEl = document.querySelector(`a-entity[${this.data.targetComponent}]`);
+    if (this.characterEl) {
+      this._initPostProcessing = this._initPostProcessing.bind(this);
+      this.characterEl.addEventListener("componentinitialized", this._initPostProcessing);
+    } else {
+      console.warn("Could not find target component.");
+    }
+    this._enterVR = this._enterVR.bind(this);
+    this._exitVR = this._exitVR.bind(this);
+    this.scene.addEventListener("enter-vr", this._enterVR);
+    this.scene.addEventListener("exit-vr", this._exitVR);
+  },
+
+  pause: function() {
+    if (!this.characterEl) {
+      return;
+    }
+    this.characterEl.removeEventListener("componentinitialized", this._initPostProcessing);
+    this.scene.removeEventListener("enter-vr", this._enterVR);
+    this.scene.removeEventListener("exit-vr", this._exitVR);
+  },
+
+  play: function() {
+    this.scene.addEventListener("enter-vr", this._enterVR);
+    this.scene.addEventListener("exit-vr", this._exitVR);
+  },
+
+  tick: function(t, dt) {
+    this.dt = dt;
+
+    if (disabledByQueryString || !this.isPostProcessingReady || !this.isVR) {
+      return;
+    }
+
+    const { maxSpeed, minRadius, softest } = this.data;
+    const characterSpeed = this.characterComponent.velocity.length();
+    const shaderRadius = this.vignettePass.uniforms["radius"].value || NO_TUNNEL_RADIUS;
+    if (!this.enabled && characterSpeed > CLAMP_SPEED) {
+      this.enabled = true;
+      this._bindRenderFunc();
+    } else if (
+      this.enabled &&
+      characterSpeed < CLAMP_SPEED &&
+      Math.abs(NO_TUNNEL_RADIUS - shaderRadius) < CLAMP_RADIUS
+    ) {
+      this.enabled = false;
+      this._exitTunnel();
+    }
+    if (this.enabled) {
+      const clampedSpeed = characterSpeed > maxSpeed ? maxSpeed : characterSpeed;
+      const speedRatio = clampedSpeed / maxSpeed;
+      this.targetRadius = lerp(NO_TUNNEL_RADIUS, minRadius, f(speedRatio));
+      this.targetSoftness = lerp(NO_TUNNEL_SOFTNESS, softest, f(speedRatio));
+      this._updateVignettePass(this.targetRadius, this.targetSoftness, this.data.opacity);
+    }
+  },
+
+  _exitTunnel: function() {
+    this.scene.renderer.render = this.originalRenderFunc;
+    this.isMoving = false;
+  },
+
+  _initPostProcessing: function(event) {
+    if (event.detail.name === this.data.targetComponent) {
+      this.characterEl.removeEventListener("componentinitialized", this._initPostProcessing);
+      this.characterComponent = this.characterEl.components[this.data.targetComponent];
+      this._initComposer();
+    }
+  },
+
+  _enterVR: function() {
+    this.isVR = true; //TODO: This is called in 2D mode when you press "f", which is bad
+  },
+
+  _exitVR: function() {
+    this._exitTunnel();
+    this.isVR = false;
+  },
+
+  _initComposer: function() {
+    this.renderer = this.scene.renderer;
+    this.camera = this.scene.camera;
+    this.originalRenderFunc = this.scene.renderer.render;
+    this.isDigest = false;
+    const render = this.scene.renderer.render;
+    const system = this;
+    this.postProcessingRenderFunc = function() {
+      if (system.isDigest) {
+        render.apply(this, arguments);
+      } else {
+        system.isDigest = true;
+        system.composer.render(system.dt);
+        system.isDigest = false;
+      }
+    };
+    this.composer = new THREE.EffectComposer(this.renderer);
+    this.composer.resize();
+    this.scenePass = new THREE.RenderPass(this.scene.object3D, this.camera);
+    this.vignettePass = new THREE.ShaderPass(THREE.VignetteShader);
+    this._updateVignettePass(this.data.radius, this.data.softness, this.data.opacity);
+    this.composer.addPass(this.scenePass);
+    this.composer.addPass(this.vignettePass);
+    this.isPostProcessingReady = true;
+  },
+
+  _updateVignettePass: function(radius, softness, opacity) {
+    const { width, height } = this.renderer.getSize();
+    const pixelRatio = this.renderer.getPixelRatio();
+    this.vignettePass.uniforms["radius"].value = radius;
+    this.vignettePass.uniforms["softness"].value = softness;
+    this.vignettePass.uniforms["opacity"].value = opacity;
+    this.vignettePass["resolution"] = new THREE.Uniform(new THREE.Vector2(width * pixelRatio, height * pixelRatio));
+    if (!this.vignettePass.renderToScreen) {
+      this.vignettePass.renderToScreen = true;
+    }
+  },
+
+  /**
+   * use the render func of the effect composer when we need the postprocessing
+   */
+  _bindRenderFunc: function() {
+    this.scene.renderer.render = this.postProcessingRenderFunc;
+  }
+});
diff --git a/src/utils/action-event-handler.js b/src/utils/action-event-handler.js
index 6288c34e002af618390ab63185a47830067f39f1..472dd813a75ce4bbd11036057550d51105b9be2b 100644
--- a/src/utils/action-event-handler.js
+++ b/src/utils/action-event-handler.js
@@ -1,29 +1,47 @@
+const VERTICAL_SCROLL_TIMEOUT = 150;
+const HORIZONTAL_SCROLL_TIMEOUT = 150;
+const SCROLL_THRESHOLD = 0.05;
+const SCROLL_MODIFIER = 0.1;
+
 export default class ActionEventHandler {
   constructor(scene, cursor) {
     this.scene = scene;
     this.cursor = cursor;
+    this.cursorHand = this.cursor.data.cursor.components["super-hands"];
     this.isCursorInteracting = false;
-    this.isCursorInteractingOnGrab = false;
     this.isTeleporting = false;
     this.handThatAlsoDrivesCursor = null;
     this.hovered = false;
 
+    this.gotPrimaryDown = false;
+
     this.onPrimaryDown = this.onPrimaryDown.bind(this);
     this.onPrimaryUp = this.onPrimaryUp.bind(this);
-    this.onGrab = this.onGrab.bind(this);
-    this.onRelease = this.onRelease.bind(this);
+    this.onSecondaryDown = this.onSecondaryDown.bind(this);
+    this.onSecondaryUp = this.onSecondaryUp.bind(this);
+    this.onPrimaryGrab = this.onPrimaryGrab.bind(this);
+    this.onPrimaryRelease = this.onPrimaryRelease.bind(this);
+    this.onSecondaryGrab = this.onSecondaryGrab.bind(this);
+    this.onSecondaryRelease = this.onSecondaryRelease.bind(this);
     this.onCardboardButtonDown = this.onCardboardButtonDown.bind(this);
     this.onCardboardButtonUp = this.onCardboardButtonUp.bind(this);
-    this.onMoveDuck = this.onMoveDuck.bind(this);
+    this.onScrollMove = this.onScrollMove.bind(this);
     this.addEventListeners();
+
+    this.lastVerticalScrollTime = 0;
+    this.lastHorizontalScrollTime = 0;
   }
 
   addEventListeners() {
     this.scene.addEventListener("action_primary_down", this.onPrimaryDown);
     this.scene.addEventListener("action_primary_up", this.onPrimaryUp);
-    this.scene.addEventListener("action_grab", this.onGrab);
-    this.scene.addEventListener("action_release", this.onRelease);
-    this.scene.addEventListener("move_duck", this.onMoveDuck);
+    this.scene.addEventListener("action_secondary_down", this.onSecondaryDown);
+    this.scene.addEventListener("action_secondary_up", this.onSecondaryUp);
+    this.scene.addEventListener("primary_action_grab", this.onPrimaryGrab);
+    this.scene.addEventListener("primary_action_release", this.onPrimaryRelease);
+    this.scene.addEventListener("secondary_action_grab", this.onSecondaryGrab);
+    this.scene.addEventListener("secondary_action_release", this.onSecondaryRelease);
+    this.scene.addEventListener("scroll_move", this.onScrollMove);
     this.scene.addEventListener("cardboardbuttondown", this.onCardboardButtonDown); // TODO: These should be actions
     this.scene.addEventListener("cardboardbuttonup", this.onCardboardButtonUp);
   }
@@ -31,97 +49,168 @@ export default class ActionEventHandler {
   tearDown() {
     this.scene.removeEventListener("action_primary_down", this.onPrimaryDown);
     this.scene.removeEventListener("action_primary_up", this.onPrimaryUp);
-    this.scene.removeEventListener("action_grab", this.onGrab);
-    this.scene.removeEventListener("action_release", this.onRelease);
-    this.scene.removeEventListener("move_duck", this.onMoveDuck);
+    this.scene.removeEventListener("action_secondary_down", this.onSecondaryDown);
+    this.scene.removeEventListener("action_secondary_up", this.onSecondaryUp);
+    this.scene.removeEventListener("primary_action_grab", this.onPrimaryGrab);
+    this.scene.removeEventListener("primary_action_release", this.onPrimaryRelease);
+    this.scene.removeEventListener("secondary_action_grab", this.onSecondaryGrab);
+    this.scene.removeEventListener("secondary_action_release", this.onSecondaryRelease);
+    this.scene.removeEventListener("scroll_move", this.onScrollMove);
     this.scene.removeEventListener("cardboardbuttondown", this.onCardboardButtonDown);
     this.scene.removeEventListener("cardboardbuttonup", this.onCardboardButtonUp);
   }
 
-  onMoveDuck(e) {
-    this.cursor.changeDistanceMod(-e.detail.axis[1] / 8);
+  onScrollMove(e) {
+    let scrollY = e.detail.axis[1] * SCROLL_MODIFIER;
+    scrollY = Math.abs(scrollY) > SCROLL_THRESHOLD ? scrollY : 0;
+    const changed = this.cursor.changeDistanceMod(-scrollY); //TODO: don't negate this for certain controllers
+
+    let scrollX = e.detail.axis[0] * SCROLL_MODIFIER;
+    scrollX = Math.abs(scrollX) > SCROLL_THRESHOLD ? scrollX : 0;
+
+    this.isCursorInteracting = this.cursor.isInteracting();
+
+    if (
+      Math.abs(scrollY) > 0 &&
+      (this.lastVerticalScrollTime === 0 || this.lastVerticalScrollTime + VERTICAL_SCROLL_TIMEOUT < Date.now())
+    ) {
+      if (!changed && this.isCursorInteracting && this.isHandThatAlsoDrivesCursor(e.target)) {
+        this.cursorHand.el.emit(scrollY < 0 ? "scroll_up" : "scroll_down");
+        this.cursorHand.el.emit("vertical_scroll_release");
+      } else {
+        e.target.emit(scrollY < 0 ? "scroll_up" : "scroll_down");
+        e.target.emit("vertical_scroll_release");
+      }
+      this.lastVerticalScrollTime = Date.now();
+    }
+
+    if (
+      Math.abs(scrollX) > 0 &&
+      (this.lastHorizontalScrollTime === 0 || this.lastHorizontalScrollTime + HORIZONTAL_SCROLL_TIMEOUT < Date.now())
+    ) {
+      if (this.isCursorInteracting && this.isHandThatAlsoDrivesCursor(e.target)) {
+        this.cursorHand.el.emit(scrollX < 0 ? "scroll_left" : "scroll_right");
+        this.cursorHand.el.emit("horizontal_scroll_release");
+      } else {
+        e.target.emit(scrollX < 0 ? "scroll_left" : "scroll_right");
+        e.target.emit("horizontal_scroll_release");
+      }
+      this.lastHorizontalScrollTime = Date.now();
+    }
   }
 
   setHandThatAlsoDrivesCursor(handThatAlsoDrivesCursor) {
     this.handThatAlsoDrivesCursor = handThatAlsoDrivesCursor;
   }
 
-  onGrab(e) {
-    if (this.handThatAlsoDrivesCursor && this.handThatAlsoDrivesCursor === e.target) {
-      if (this.isCursorInteracting) {
-        return;
-      } else if (e.target.components["super-hands"].state.has("hover-start")) {
-        e.target.emit("hand_grab");
-        return;
+  isToggle(el) {
+    return el && el.matches(".toggle, .toggle *");
+  }
+
+  isHandThatAlsoDrivesCursor(el) {
+    return this.handThatAlsoDrivesCursor === el;
+  }
+
+  onGrab(e, event) {
+    event = event || e.type;
+    const superHand = e.target.components["super-hands"];
+    const isCursorHand = this.isHandThatAlsoDrivesCursor(e.target);
+    this.isCursorInteracting = this.cursor.isInteracting();
+    if (isCursorHand && !this.isCursorInteracting) {
+      if (superHand.state.has("hover-start") || superHand.state.get("grab-start")) {
+        e.target.emit(event);
       } else {
         this.isCursorInteracting = this.cursor.startInteraction();
-        if (this.isCursorInteracting) {
-          this.isCursorInteractingOnGrab = true;
-        }
-        return;
       }
+    } else if (isCursorHand && this.isCursorInteracting) {
+      this.cursorHand.el.emit(event);
     } else {
-      e.target.emit("hand_grab");
-      return;
+      e.target.emit(event);
     }
   }
 
-  onRelease(e) {
+  onRelease(e, event) {
+    event = event || e.type;
+    const isCursorHand = this.isHandThatAlsoDrivesCursor(e.target);
+    if (this.isCursorInteracting && isCursorHand) {
+      //need to check both grab-start and hover-start in the case that the spawner is being grabbed this frame
+      if (this.isToggle(this.cursorHand.state.get("grab-start") || this.cursorHand.state.get("hover-start"))) {
+        this.cursorHand.el.emit(event);
+        this.isCursorInteracting = this.cursor.isInteracting();
+      } else {
+        this.isCursorInteracting = false;
+        this.cursor.endInteraction();
+      }
+    } else {
+      e.target.emit(event);
+    }
+  }
+
+  onPrimaryGrab(e) {
+    this.onGrab(e, "primary_hand_grab");
+  }
+
+  onPrimaryRelease(e) {
+    this.onRelease(e, "primary_hand_release");
+  }
+
+  onSecondaryGrab(e) {
+    this.onGrab(e, "secondary_hand_grab");
+  }
+
+  onSecondaryRelease(e) {
+    this.onRelease(e, "secondary_hand_release");
+  }
+
+  onDown(e, event) {
+    this.onGrab(e, event);
+
     if (
-      this.isCursorInteracting &&
-      this.isCursorInteractingOnGrab &&
-      this.handThatAlsoDrivesCursor &&
-      this.handThatAlsoDrivesCursor === e.target
+      this.isHandThatAlsoDrivesCursor(e.target) &&
+      !this.isCursorInteracting &&
+      !this.cursorHand.state.get("grab-start")
     ) {
-      this.isCursorInteracting = false;
-      this.isCursorInteractingOnGrab = false;
-      this.cursor.endInteraction();
+      this.cursor.setCursorVisibility(false);
+      const button = e.target.components["teleport-controls"].data.button;
+      e.target.emit(button + "down");
+      this.isTeleporting = true;
+    }
+  }
+
+  onUp(e, event) {
+    if (this.isTeleporting && this.isHandThatAlsoDrivesCursor(e.target)) {
+      const superHand = e.target.components["super-hands"];
+      this.cursor.setCursorVisibility(!superHand.state.has("hover-start"));
+      const button = e.target.components["teleport-controls"].data.button;
+      e.target.emit(button + "up");
+      this.isTeleporting = false;
     } else {
-      e.target.emit("hand_release");
+      this.onRelease(e, event);
     }
   }
 
   onPrimaryDown(e) {
-    if (this.isCursorInteractingOnGrab) return;
-    if (this.handThatAlsoDrivesCursor && this.handThatAlsoDrivesCursor === e.target) {
-      if (this.isCursorInteracting) {
-        return;
-      } else if (e.target.components["super-hands"].state.has("hover-start")) {
-        e.target.emit("hand_grab");
-        return;
-      } else {
-        this.isCursorInteracting = this.cursor.startInteraction();
-        if (this.isCursorInteracting) return;
-      }
+    if (!this.gotPrimaryDown) {
+      this.onDown(e, "primary_hand_grab");
+      this.gotPrimaryDown = true;
     }
-
-    this.cursor.setCursorVisibility(false);
-    const button = e.target.components["teleport-controls"].data.button;
-    e.target.emit(button + "down");
-    this.isTeleporting = true;
   }
 
   onPrimaryUp(e) {
-    if (this.isCursorInteractingOnGrab) return;
-    const isCursorHand = this.handThatAlsoDrivesCursor && this.handThatAlsoDrivesCursor === e.target;
-    if (this.isCursorInteracting && isCursorHand) {
-      this.isCursorInteracting = false;
-      this.cursor.endInteraction();
-      return;
+    if (this.gotPrimaryDown) {
+      this.onUp(e, "primary_hand_release");
+    } else if (this.isToggle(this.cursorHand.state.get("grab-start") || this.cursorHand.state.get("hover-start"))) {
+      this.onUp(e, "secondary_hand_release");
     }
+    this.gotPrimaryDown = false;
+  }
 
-    const state = e.target.components["super-hands"].state;
-    if (state.has("grab-start")) {
-      e.target.emit("hand_release");
-      return;
-    }
+  onSecondaryDown(e) {
+    this.onDown(e, "secondary_hand_grab");
+  }
 
-    if (isCursorHand) {
-      this.cursor.setCursorVisibility(!state.has("hover-start"));
-    }
-    const button = e.target.components["teleport-controls"].data.button;
-    e.target.emit(button + "up");
-    this.isTeleporting = false;
+  onSecondaryUp(e) {
+    this.onUp(e, "secondary_hand_release");
   }
 
   onCardboardButtonDown(e) {
diff --git a/src/utils/media-utils.js b/src/utils/media-utils.js
index 83a5f788ea841c1f3343c2068293b97bf9f53f3e..829d00de9709d3d2ce5214c884edf63b1dac6a35 100644
--- a/src/utils/media-utils.js
+++ b/src/utils/media-utils.js
@@ -51,16 +51,64 @@ export const upload = file => {
   }).then(r => r.json());
 };
 
+// https://stackoverflow.com/questions/7584794/accessing-jpeg-exif-rotation-data-in-javascript-on-the-client-side/32490603#32490603
+function getOrientation(file, callback) {
+  const reader = new FileReader();
+  reader.onload = function(e) {
+    const view = new DataView(e.target.result);
+    if (view.getUint16(0, false) != 0xffd8) {
+      return callback(-2);
+    }
+    const length = view.byteLength;
+    let offset = 2;
+    while (offset < length) {
+      if (view.getUint16(offset + 2, false) <= 8) return callback(-1);
+      const marker = view.getUint16(offset, false);
+      offset += 2;
+      if (marker == 0xffe1) {
+        if (view.getUint32((offset += 2), false) != 0x45786966) {
+          return callback(-1);
+        }
+
+        const little = view.getUint16((offset += 6), false) == 0x4949;
+        offset += view.getUint32(offset + 4, little);
+        const tags = view.getUint16(offset, little);
+        offset += 2;
+        for (let i = 0; i < tags; i++) {
+          if (view.getUint16(offset + i * 12, little) == 0x0112) {
+            return callback(view.getUint16(offset + i * 12 + 8, little));
+          }
+        }
+      } else if ((marker & 0xff00) != 0xff00) {
+        break;
+      } else {
+        offset += view.getUint16(offset, false);
+      }
+    }
+    return callback(-1);
+  };
+  reader.readAsArrayBuffer(file);
+}
+
 let interactableId = 0;
-export const addMedia = (src, contentOrigin, resize = false) => {
+export const addMedia = (src, template, contentOrigin, resize = false) => {
   const scene = AFRAME.scenes[0];
 
   const entity = document.createElement("a-entity");
   entity.id = "interactable-media-" + interactableId++;
-  entity.setAttribute("networked", { template: "#interactable-media" });
+  entity.setAttribute("networked", { template: template });
   entity.setAttribute("media-loader", { resize, src: typeof src === "string" ? src : "" });
   scene.appendChild(entity);
 
+  const orientation = new Promise(function(resolve) {
+    if (src instanceof File) {
+      getOrientation(src, x => {
+        resolve(x);
+      });
+    } else {
+      resolve(1);
+    }
+  });
   if (src instanceof File) {
     upload(src)
       .then(response => {
@@ -78,5 +126,5 @@ export const addMedia = (src, contentOrigin, resize = false) => {
     scene.emit("object_spawned", { objectType });
   });
 
-  return entity;
+  return { entity, orientation };
 };
diff --git a/src/utils/mouse-events-handler.js b/src/utils/mouse-events-handler.js
index 0fdb330c6558e397e395908e4b0c09fa8c15d1f0..510cb0cd25dd0ca792c294a73e544c60beb1b084 100644
--- a/src/utils/mouse-events-handler.js
+++ b/src/utils/mouse-events-handler.js
@@ -1,10 +1,14 @@
 // TODO: Make look speed adjustable by the user
 const HORIZONTAL_LOOK_SPEED = 0.1;
 const VERTICAL_LOOK_SPEED = 0.06;
+const VERTICAL_SCROLL_TIMEOUT = 50;
+const HORIZONTAL_SCROLL_TIMEOUT = 50;
 
 export default class MouseEventsHandler {
   constructor(cursor, cameraController) {
     this.cursor = cursor;
+    const cursorController = this.cursor.el.getAttribute("cursor-controller");
+    this.superHand = cursorController.cursor.components["super-hands"];
     this.cameraController = cameraController;
     this.isLeftButtonDown = false;
     this.isLeftButtonHandledByCursor = false;
@@ -16,6 +20,9 @@ export default class MouseEventsHandler {
     this.onMouseWheel = this.onMouseWheel.bind(this);
 
     this.addEventListeners();
+
+    this.lastVerticalScrollTime = 0;
+    this.lastHorizontalScrollTime = 0;
   }
 
   tearDown() {
@@ -43,46 +50,83 @@ export default class MouseEventsHandler {
   }
 
   onMouseDown(e) {
-    const isLeftButton = e.button === 0;
-    const isRightButton = e.button === 2;
-    if (isLeftButton) {
-      this.onLeftButtonDown();
-    } else if (isRightButton) {
-      this.onRightButtonDown();
+    switch (e.button) {
+      case 0: //left button
+        this.onLeftButtonDown();
+        break;
+      case 1: //middle/scroll button
+        //TODO: rotation? scaling?
+        break;
+      case 2: //right button
+        this.onRightButtonDown();
+        break;
     }
   }
 
   onLeftButtonDown() {
     this.isLeftButtonDown = true;
+    if (this.isToggle(this.superHand.state.get("grab-start"))) {
+      this.superHand.el.emit("secondary-cursor-grab");
+    }
     this.isLeftButtonHandledByCursor = this.cursor.startInteraction();
   }
 
   onRightButtonDown() {
-    if (this.isPointerLocked) {
-      document.exitPointerLock();
-      this.isPointerLocked = false;
-    } else {
-      document.body.requestPointerLock();
-      this.isPointerLocked = true;
+    this.isLeftButtonHandledByCursor = this.cursor.isInteracting();
+    if (!this.isLeftButtonHandledByCursor) {
+      if (this.isPointerLocked) {
+        document.exitPointerLock();
+        this.isPointerLocked = false;
+      } else {
+        document.body.requestPointerLock();
+        this.isPointerLocked = true;
+      }
     }
   }
 
   onMouseWheel(e) {
-    switch (e.deltaMode) {
-      case e.DOM_DELTA_PIXEL:
-        this.cursor.changeDistanceMod(e.deltaY / 500);
-        break;
-      case e.DOM_DELTA_LINE:
-        this.cursor.changeDistanceMod(e.deltaY / 10);
-        break;
-      case e.DOM_DELTA_PAGE:
-        this.cursor.changeDistanceMod(e.deltaY / 2);
-        break;
+    let changed = true;
+    if (!e.altKey && !e.shiftKey) {
+      changed = this.cursor.changeDistanceMod(this.getScrollMod(e.deltaY, e.deltaMode));
+    }
+
+    if (
+      (!changed || e.shiftKey) &&
+      (this.lastVerticalScrollTime === 0 || this.lastVerticalScrollTime + VERTICAL_SCROLL_TIMEOUT < Date.now())
+    ) {
+      this.superHand.el.emit(e.deltaY > 0 ? "scroll_up" : "scroll_down");
+      this.superHand.el.emit("vertical_scroll_release");
+      this.lastVerticalScrollTime = Date.now();
+    }
+
+    const delta = e.altKey ? e.deltaY : e.deltaX;
+    if (
+      Math.abs(delta) > 0 &&
+      (this.lastHorizontalScrollTime === 0 || this.lastHorizontalScrollTime + HORIZONTAL_SCROLL_TIMEOUT < Date.now())
+    ) {
+      this.superHand.el.emit(delta < 0 ? "scroll_left" : "scroll_right");
+      this.superHand.el.emit("horizontal_scroll_release");
+      this.lastHorizontalScrollTime = Date.now();
+    }
+
+    if (e.altKey) e.preventDefault(); //prevent forward/back on firefox
+  }
+
+  getScrollMod(delta, deltaMode) {
+    switch (deltaMode) {
+      case WheelEvent.DOM_DELTA_PIXEL:
+        return delta / 500;
+      case WheelEvent.DOM_DELTA_LINE:
+        return delta / 10;
+      case WheelEvent.DOM_DELTA_PAGE:
+        return delta / 2;
     }
   }
 
   onMouseMove(e) {
-    const shouldLook = this.isPointerLocked || (this.isLeftButtonDown && !this.isLeftButtonHandledByCursor);
+    const shouldLook =
+      this.isPointerLocked ||
+      (!this.superHand.state.get("grab-start") && this.isLeftButtonDown && !this.isLeftButtonHandledByCursor);
     if (shouldLook) {
       this.look(e);
     }
@@ -91,14 +135,30 @@ export default class MouseEventsHandler {
   }
 
   onMouseUp(e) {
-    const isLeftButton = e.button === 0;
-    if (!isLeftButton) return;
-
-    if (this.isLeftButtonHandledByCursor) {
-      this.cursor.endInteraction();
+    switch (e.button) {
+      case 0: //left button
+        if (this.isToggle(this.superHand.state.get("grab-start"))) {
+          this.superHand.el.emit("secondary-cursor-release");
+        } else {
+          this.endInteraction();
+        }
+        this.isLeftButtonDown = false;
+        break;
+      case 1: //middle/scroll button
+        break;
+      case 2: //right button
+        this.endInteraction();
+        break;
     }
+  }
+
+  endInteraction() {
+    this.cursor.endInteraction();
     this.isLeftButtonHandledByCursor = false;
-    this.isLeftButtonDown = false;
+  }
+
+  isToggle(el) {
+    return el && el.matches(".toggle, .toggle *");
   }
 
   look(e) {
diff --git a/src/utils/postprocessing/EffectComposer.js b/src/utils/postprocessing/EffectComposer.js
new file mode 100644
index 0000000000000000000000000000000000000000..11fe7447817d002e86973b14856a5f2bc4a85de9
--- /dev/null
+++ b/src/utils/postprocessing/EffectComposer.js
@@ -0,0 +1,167 @@
+THREE.EffectComposer = function(renderer, renderTarget) {
+  this.renderer = renderer;
+  this.delta = 0;
+  window.addEventListener("vrdisplaypresentchange", this.resize.bind(this));
+
+  if (renderTarget === undefined) {
+    const parameters = {
+      minFilter: THREE.LinearFilter,
+      magFilter: THREE.LinearFilter,
+      format: THREE.RGBAFormat,
+      stencilBuffer: false
+    };
+
+    const size = renderer.getDrawingBufferSize();
+    renderTarget = new THREE.WebGLRenderTarget(size.width, size.height, parameters);
+    renderTarget.texture.name = "EffectComposer.rt1";
+  }
+
+  this.renderTarget1 = renderTarget;
+  this.renderTarget2 = renderTarget.clone();
+  this.renderTarget2.texture.name = "EffectComposer.rt2";
+
+  this.writeBuffer = this.renderTarget1;
+  this.readBuffer = this.renderTarget2;
+
+  this.passes = [];
+  this.maskActive = false;
+
+  // dependencies
+
+  if (THREE.CopyShader === undefined) {
+    console.error("THREE.EffectComposer relies on THREE.CopyShader");
+  }
+
+  if (THREE.ShaderPass === undefined) {
+    console.error("THREE.EffectComposer relies on THREE.ShaderPass");
+  }
+
+  this.copyPass = new THREE.ShaderPass(THREE.CopyShader);
+};
+
+Object.assign(THREE.EffectComposer.prototype, {
+  swapBuffers: function(pass) {
+    if (pass.needsSwap) {
+      if (this.maskActive) {
+        const context = this.renderer.context;
+        context.stencilFunc(context.NOTEQUAL, 1, 0xffffffff);
+        this.copyPass.render(this.renderer, this.writeBuffer, this.readBuffer, this.delta);
+        context.stencilFunc(context.EQUAL, 1, 0xffffffff);
+      }
+
+      const tmp = this.readBuffer;
+      this.readBuffer = this.writeBuffer;
+      this.writeBuffer = tmp;
+    }
+
+    if (THREE.MaskPass !== undefined) {
+      if (pass instanceof THREE.MaskPass) {
+        this.maskActive = true;
+      } else if (pass instanceof THREE.ClearMaskPass) {
+        this.maskActive = false;
+      }
+    }
+  },
+
+  addPass: function(pass) {
+    this.passes.push(pass);
+    const size = this.renderer.getDrawingBufferSize();
+    pass.setSize(size.width, size.height);
+  },
+
+  insertPass: function(pass, index) {
+    this.passes.splice(index, 0, pass);
+  },
+
+  render: function(delta, starti) {
+    const maskActive = this.maskActive;
+    let pass;
+    let i;
+    const il = this.passes.length;
+    const scope = this;
+    let currentOnAfterRender;
+    this.delta = delta;
+
+    for (i = starti || 0; i < il; i++) {
+      pass = this.passes[i];
+      if (pass.enabled === false) continue;
+
+      // If VR mode is enabled and rendering the whole scene is required.
+      // The pass renders the scene and and postprocessing is resumed before
+      // submitting the frame to the headset by using the onAfterRender callback.
+      if (this.renderer.vr.enabled && pass.scene) {
+        currentOnAfterRender = pass.scene.onAfterRender;
+        pass.scene.onAfterRender = function() {
+          // Disable stereo rendering when doing postprocessing
+          // on a render target.
+          scope.renderer.vr.enabled = false;
+          scope.render(delta, i + 1, maskActive);
+
+          // Renable vr mode.
+          scope.renderer.vr.enabled = true;
+        };
+
+        pass.render(this.renderer, this.writeBuffer, this.readBuffer);
+
+        // Restore onAfterRender
+        pass.scene.onAfterRender = currentOnAfterRender;
+        this.swapBuffers(pass);
+        return;
+      }
+
+      pass.render(this.renderer, this.writeBuffer, this.readBuffer);
+      this.swapBuffers(pass);
+    }
+  },
+
+  reset: function(renderTarget) {
+    if (renderTarget === undefined) {
+      const size = this.renderer.getDrawingBufferSize();
+      renderTarget = this.renderTarget1.clone();
+      renderTarget.setSize(size.width, size.height);
+    }
+
+    this.renderTarget1.dispose();
+    this.renderTarget2.dispose();
+    this.renderTarget1 = renderTarget;
+    this.renderTarget2 = renderTarget.clone();
+
+    this.writeBuffer = this.renderTarget1;
+    this.readBuffer = this.renderTarget2;
+  },
+
+  setSize: function(width, height) {
+    this.renderTarget1.setSize(width, height);
+    this.renderTarget2.setSize(width, height);
+    for (let i = 0; i < this.passes.length; i++) {
+      this.passes[i].setSize(width, height);
+    }
+  },
+
+  resize: function() {
+    const rendererSize = this.renderer.getDrawingBufferSize();
+    this.setSize(rendererSize.width, rendererSize.height);
+  }
+});
+
+THREE.Pass = function() {
+  // if set to true, the pass is processed by the composer
+  this.enabled = true;
+
+  // if set to true, the pass indicates to swap read and write buffer after rendering
+  this.needsSwap = true;
+
+  // if set to true, the pass clears its buffer before rendering
+  this.clear = false;
+
+  // if set to true, the result of the pass is rendered to screen
+  this.renderToScreen = false;
+};
+
+Object.assign(THREE.Pass.prototype, {
+  setSize: function() {},
+
+  render: function() {
+    console.error("THREE.Pass: .render() must be implemented in derived pass.");
+  }
+});
diff --git a/src/utils/postprocessing/MaskPass.js b/src/utils/postprocessing/MaskPass.js
new file mode 100644
index 0000000000000000000000000000000000000000..ddc5aa98a39be86dc224c282a9e02837e9aa7f14
--- /dev/null
+++ b/src/utils/postprocessing/MaskPass.js
@@ -0,0 +1,80 @@
+/**
+ * @author alteredq / http://alteredqualia.com/
+ */
+
+THREE.MaskPass = function(scene, camera) {
+  THREE.Pass.call(this);
+
+  this.scene = scene;
+  this.camera = camera;
+
+  this.clear = true;
+  this.needsSwap = false;
+
+  this.inverse = false;
+};
+
+THREE.MaskPass.prototype = Object.assign(Object.create(THREE.Pass.prototype), {
+  constructor: THREE.MaskPass,
+
+  render: function(renderer, writeBuffer, readBuffer) {
+    const context = renderer.context;
+    const state = renderer.state;
+
+    // don't update color or depth
+
+    state.buffers.color.setMask(false);
+    state.buffers.depth.setMask(false);
+
+    // lock buffers
+
+    state.buffers.color.setLocked(true);
+    state.buffers.depth.setLocked(true);
+
+    // set up stencil
+
+    let writeValue, clearValue;
+
+    if (this.inverse) {
+      writeValue = 0;
+      clearValue = 1;
+    } else {
+      writeValue = 1;
+      clearValue = 0;
+    }
+
+    state.buffers.stencil.setTest(true);
+    state.buffers.stencil.setOp(context.REPLACE, context.REPLACE, context.REPLACE);
+    state.buffers.stencil.setFunc(context.ALWAYS, writeValue, 0xffffffff);
+    state.buffers.stencil.setClear(clearValue);
+
+    // draw into the stencil buffer
+
+    renderer.render(this.scene, this.camera, readBuffer, this.clear);
+    renderer.render(this.scene, this.camera, writeBuffer, this.clear);
+
+    // unlock color and depth buffer for subsequent rendering
+
+    state.buffers.color.setLocked(false);
+    state.buffers.depth.setLocked(false);
+
+    // only render where stencil is set to 1
+
+    state.buffers.stencil.setFunc(context.EQUAL, 1, 0xffffffff); // draw if == 1
+    state.buffers.stencil.setOp(context.KEEP, context.KEEP, context.KEEP);
+  }
+});
+
+THREE.ClearMaskPass = function() {
+  THREE.Pass.call(this);
+
+  this.needsSwap = false;
+};
+
+THREE.ClearMaskPass.prototype = Object.create(THREE.Pass.prototype);
+
+Object.assign(THREE.ClearMaskPass.prototype, {
+  render: function(renderer) {
+    renderer.state.buffers.stencil.setTest(false);
+  }
+});
diff --git a/src/utils/postprocessing/README.md b/src/utils/postprocessing/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..1385db1d2b5b53f0325fe39020b0de2f4d91144d
--- /dev/null
+++ b/src/utils/postprocessing/README.md
@@ -0,0 +1,7 @@
+These files
+- EffectComposer.js
+- MaskPass.js
+- RenderPass.js
+- ShaderPass.js
+are copied from https://github.com/mrdoob/three.js/blob/dev/examples/js/postprocessing/EffectComposer.js
+
diff --git a/src/utils/postprocessing/RenderPass.js b/src/utils/postprocessing/RenderPass.js
new file mode 100644
index 0000000000000000000000000000000000000000..e7dd98af6f12876c0c179147fb1b3e20836772a8
--- /dev/null
+++ b/src/utils/postprocessing/RenderPass.js
@@ -0,0 +1,52 @@
+/**
+ * @author alteredq / http://alteredqualia.com/
+ */
+
+THREE.RenderPass = function(scene, camera, overrideMaterial, clearColor, clearAlpha) {
+  THREE.Pass.call(this);
+
+  this.scene = scene;
+  this.camera = camera;
+
+  this.overrideMaterial = overrideMaterial;
+
+  this.clearColor = clearColor;
+  this.clearAlpha = clearAlpha !== undefined ? clearAlpha : 0;
+
+  this.clear = true;
+  this.clearDepth = false;
+  this.needsSwap = false;
+};
+
+THREE.RenderPass.prototype = Object.assign(Object.create(THREE.Pass.prototype), {
+  constructor: THREE.RenderPass,
+
+  render: function(renderer, writeBuffer, readBuffer) {
+    const oldAutoClear = renderer.autoClear;
+    renderer.autoClear = false;
+
+    this.scene.overrideMaterial = this.overrideMaterial;
+
+    let oldClearColor, oldClearAlpha;
+
+    if (this.clearColor) {
+      oldClearColor = renderer.getClearColor().getHex();
+      oldClearAlpha = renderer.getClearAlpha();
+
+      renderer.setClearColor(this.clearColor, this.clearAlpha);
+    }
+
+    if (this.clearDepth) {
+      renderer.clearDepth();
+    }
+
+    renderer.render(this.scene, this.camera, this.renderToScreen ? null : readBuffer, this.clear);
+
+    if (this.clearColor) {
+      renderer.setClearColor(oldClearColor, oldClearAlpha);
+    }
+
+    this.scene.overrideMaterial = null;
+    renderer.autoClear = oldAutoClear;
+  }
+});
diff --git a/src/utils/postprocessing/ShaderPass.js b/src/utils/postprocessing/ShaderPass.js
new file mode 100644
index 0000000000000000000000000000000000000000..d6d25800f86e19df01c8e39c490b3763ecd7ef77
--- /dev/null
+++ b/src/utils/postprocessing/ShaderPass.js
@@ -0,0 +1,44 @@
+/**
+ * @author alteredq / http://alteredqualia.com/
+ */
+
+THREE.ShaderPass = function(shader, textureID) {
+  THREE.Pass.call(this);
+  this.textureID = textureID !== undefined ? textureID : "tDiffuse";
+  if (shader instanceof THREE.ShaderMaterial) {
+    this.uniforms = shader.uniforms;
+    this.material = shader;
+  } else if (shader) {
+    this.uniforms = THREE.UniformsUtils.clone(shader.uniforms);
+    this.material = new THREE.ShaderMaterial({
+      defines: Object.assign({}, shader.defines),
+      uniforms: this.uniforms,
+      vertexShader: shader.vertexShader,
+      fragmentShader: shader.fragmentShader
+    });
+  }
+
+  this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
+  this.scene = new THREE.Scene();
+
+  this.quad = new THREE.Mesh(new THREE.PlaneBufferGeometry(2, 2), null);
+  this.quad.frustumCulled = false; // Avoid getting clipped
+  this.scene.add(this.quad);
+};
+
+THREE.ShaderPass.prototype = Object.assign(Object.create(THREE.Pass.prototype), {
+  constructor: THREE.ShaderPass,
+  render: function(renderer, writeBuffer, readBuffer) {
+    if (this.uniforms[this.textureID]) {
+      this.uniforms[this.textureID].value = readBuffer.texture;
+    }
+
+    this.quad.material = this.material;
+
+    if (this.renderToScreen) {
+      renderer.render(this.scene, this.camera);
+    } else {
+      renderer.render(this.scene, this.camera, writeBuffer, this.clear);
+    }
+  }
+});
diff --git a/src/utils/shaders/CopyShader.js b/src/utils/shaders/CopyShader.js
new file mode 100644
index 0000000000000000000000000000000000000000..f970d4a92df1ae3653b45906596963c3c6b7611d
--- /dev/null
+++ b/src/utils/shaders/CopyShader.js
@@ -0,0 +1,38 @@
+/**
+ * @author alteredq / http://alteredqualia.com/
+ *
+ * Full-screen textured quad shader
+ */
+
+THREE.CopyShader = {
+  uniforms: {
+    tDiffuse: { value: null },
+    opacity: { value: 1.0 }
+  },
+
+  vertexShader: [
+    "varying vec2 vUv;",
+
+    "void main() {",
+
+    "vUv = uv;",
+    "gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );",
+
+    "}"
+  ].join("\n"),
+
+  fragmentShader: [
+    "uniform float opacity;",
+
+    "uniform sampler2D tDiffuse;",
+
+    "varying vec2 vUv;",
+
+    "void main() {",
+
+    "vec4 texel = texture2D( tDiffuse, vUv );",
+    "gl_FragColor = opacity * texel;",
+
+    "}"
+  ].join("\n")
+};
diff --git a/src/utils/shaders/README.md b/src/utils/shaders/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..6d4b23f4a3954768617953bbea2a897ded60ded5
--- /dev/null
+++ b/src/utils/shaders/README.md
@@ -0,0 +1,5 @@
+These files
+- CopyShader.js
+- VignetteShader.js
+were copied from https://github.com/mrdoob/three.js/blob/dev/examples/js/shaders/CopyShader.js
+
diff --git a/src/utils/shaders/VignetteShader.js b/src/utils/shaders/VignetteShader.js
new file mode 100644
index 0000000000000000000000000000000000000000..9a424139bc72101aea8f7251f741c9790f851bae
--- /dev/null
+++ b/src/utils/shaders/VignetteShader.js
@@ -0,0 +1,46 @@
+THREE.VignetteShader = {
+  uniforms: {
+    tDiffuse: { value: null },
+    radius: { value: 0.65 },
+    opacity: { value: 0.9 },
+    softness: { value: 0.2 },
+    resolution: new THREE.Uniform(new THREE.Vector2(1920, 1080))
+  },
+
+  vertexShader: [
+    "varying vec2 vUv;",
+    "void main() {",
+    "vUv = uv;",
+    "gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );",
+    "}"
+  ].join("\n"),
+
+  fragmentShader: [
+    "uniform sampler2D tDiffuse;",
+    "uniform float radius;",
+    "uniform float opacity;",
+    "uniform float softness;",
+    "uniform vec2 resolution;",
+
+    "varying vec2 vUv;",
+
+    "void main() {",
+    "vec4 texel = texture2D( tDiffuse, vUv);",
+    "float ratio = resolution.x / resolution.y;",
+    "float centerXOffset = radius / ratio;",
+    "float leftX = (0.3 + centerXOffset >= 0.5) ? 0.5 - centerXOffset : 0.3;",
+    "float rightX = (0.7 - centerXOffset <= 0.5) ? 0.5 + centerXOffset : 0.7;",
+    "vec2 uvLeft = (vUv.xy) - vec2(leftX, 0.5);",
+    "vec2 uvRight = (vUv.xy) - vec2(rightX, 0.5);",
+    "uvLeft.x *= ratio;",
+    "uvRight.x *= ratio;",
+    "float lenLeft = length(uvLeft);",
+    "float lenRight = length(uvRight);",
+    "float vignetteLeft = smoothstep(radius, radius-softness, lenLeft);",
+    "float vignetteRight = smoothstep(radius, radius-softness, lenRight);",
+    "float vignette = vignetteLeft + vignetteRight;",
+    "vec3 final = mix (texel.rgb, texel.rgb * vignette, opacity);",
+    "gl_FragColor = vec4(final.rgb, 1.0);",
+    "}"
+  ].join("\n")
+};
diff --git a/src/utils/sharedbuffergeometry.js b/src/utils/sharedbuffergeometry.js
new file mode 100644
index 0000000000000000000000000000000000000000..aeebd45ed273e67e2abc22ab00288053fb321781
--- /dev/null
+++ b/src/utils/sharedbuffergeometry.js
@@ -0,0 +1,117 @@
+export default class SharedBufferGeometry {
+  constructor(material, primitiveMode, maxBufferSize) {
+    this.material = material;
+    this.primitiveMode = primitiveMode;
+
+    console.log("maxBufferSize", maxBufferSize);
+    this.maxBufferSize = maxBufferSize;
+    this.geometries = [];
+    this.current = null;
+    this.drawing = new THREE.Object3D();
+    this.addBuffer();
+  }
+
+  restartPrimitive() {
+    if (this.idx.position >= this.current.attributes.position.count) {
+      console.error("maxBufferSize limit exceeded");
+    } else if (this.idx.position !== 0) {
+      let prev = (this.idx.position - 1) * 3;
+      const position = this.current.attributes.position.array;
+      this.addVertex(position[prev++], position[prev++], position[prev++]);
+
+      this.idx.color++;
+      this.idx.normal++;
+    }
+  }
+
+  remove(prevIdx, idx) {
+    // Loop through all the attributes: position, color, normal,...
+    if (this.idx.position > idx.position) {
+      for (const key in this.idx) {
+        const componentSize = 3;
+        let pos = prevIdx[key] * componentSize;
+        const start = (idx[key] + 1) * componentSize;
+        const end = this.idx[key] * componentSize;
+        for (let i = start; i < end; i++) {
+          this.current.attributes[key].array[pos++] = this.current.attributes[key].array[i];
+        }
+        const diff = idx[key] - prevIdx[key] + 1;
+        this.idx[key] -= diff;
+      }
+    } else {
+      for (const key in this.idx) {
+        const diff = idx[key] - prevIdx[key];
+        this.idx[key] -= diff;
+      }
+    }
+
+    this.update();
+  }
+
+  undo(prevIdx) {
+    this.idx = prevIdx;
+    this.update();
+  }
+
+  addBuffer() {
+    const geometry = new THREE.BufferGeometry();
+
+    const vertices = new Float32Array(this.maxBufferSize * 3);
+    const normals = new Float32Array(this.maxBufferSize * 3);
+    const colors = new Float32Array(this.maxBufferSize * 3);
+
+    const mesh = new THREE.Mesh(geometry, this.material);
+
+    mesh.drawMode = this.primitiveMode;
+
+    mesh.frustumCulled = false;
+    mesh.vertices = vertices;
+
+    const object3D = new THREE.Object3D();
+    this.drawing.add(object3D);
+    object3D.add(mesh);
+
+    geometry.setDrawRange(0, 0);
+    geometry.addAttribute("position", new THREE.BufferAttribute(vertices, 3).setDynamic(true));
+    geometry.addAttribute("normal", new THREE.BufferAttribute(normals, 3).setDynamic(true));
+    geometry.addAttribute("color", new THREE.BufferAttribute(colors, 3).setDynamic(true));
+
+    this.previous = null;
+    if (this.geometries.length > 0) {
+      this.previous = this.current;
+    }
+
+    this.idx = {
+      position: 0,
+      normal: 0,
+      color: 0
+    };
+
+    this.geometries.push(geometry);
+    this.current = geometry;
+  }
+
+  addColor(r, g, b) {
+    this.current.attributes.color.setXYZ(this.idx.color++, r, g, b);
+  }
+
+  addNormal(x, y, z) {
+    this.current.attributes.normal.setXYZ(this.idx.normal++, x, y, z);
+  }
+
+  addVertex(x, y, z) {
+    const buffer = this.current.attributes.position;
+    if (this.idx.position === buffer.count) {
+      console.error("maxBufferSize limit exceeded");
+    }
+    buffer.setXYZ(this.idx.position++, x, y, z);
+  }
+
+  update() {
+    this.current.setDrawRange(0, this.idx.position);
+
+    this.current.attributes.color.needsUpdate = true;
+    this.current.attributes.normal.needsUpdate = true;
+    this.current.attributes.position.needsUpdate = true;
+  }
+}
diff --git a/src/utils/sharedbuffergeometrymanager.js b/src/utils/sharedbuffergeometrymanager.js
new file mode 100644
index 0000000000000000000000000000000000000000..a3f86d44e73cfa8b6f86cb712d4b9c00d4d5d822
--- /dev/null
+++ b/src/utils/sharedbuffergeometrymanager.js
@@ -0,0 +1,15 @@
+import SharedBufferGeometry from "./sharedbuffergeometry";
+
+export default class SharedBufferGeometryManager {
+  constructor() {
+    this.sharedBuffers = {};
+  }
+
+  addSharedBuffer(name, material, primitiveMode, maxBufferSize) {
+    this.sharedBuffers[name] = new SharedBufferGeometry(material, primitiveMode, maxBufferSize);
+  }
+
+  getSharedBuffer(name) {
+    return this.sharedBuffers[name];
+  }
+}
diff --git a/src/utils/vr-caps-detect.js b/src/utils/vr-caps-detect.js
index 3294820125788e0343eb5f70277eefc48b75a2c2..d5ad87eff808d1cb528c76b068de0c855d0bb3a0 100644
--- a/src/utils/vr-caps-detect.js
+++ b/src/utils/vr-caps-detect.js
@@ -63,8 +63,7 @@ export async function getAvailableVREntryTypes() {
     : VR_DEVICE_AVAILABILITY.no;
 
   const displays = isWebVRCapableBrowser ? await navigator.getVRDisplays() : [];
-  const isFirefoxReality = window.orientation === 0 && "buildID" in navigator && displays.length > 0;
-  const isInHMD = isOculusBrowser || isFirefoxReality;
+  const isInHMD = isOculusBrowser;
 
   const screen = isInHMD
     ? VR_DEVICE_AVAILABILITY.no
diff --git a/webpack.config.js b/webpack.config.js
index 3e481d53ccfc6cbdc15365ef51d6c11978a5f6ee..9531ceb26e70881f4bd4b5af7eb4367e2141f007 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -157,11 +157,11 @@ module.exports = (env, argv) => ({
 
   optimization: {
     // necessary due to https://github.com/visionmedia/debug/issues/547
-    minimizer: [new UglifyJsPlugin({ uglifyOptions: { compress: { collapse_vars: false } } })],
+    minimizer: [new UglifyJsPlugin({ sourceMap: true, uglifyOptions: { compress: { collapse_vars: false } } })],
     splitChunks: {
       cacheGroups: {
         engine: {
-          test: /[\\/]node_modules[\\/](aframe|cannon|three\.js)/,
+          test: /([\\/]src[\\/]workers|[\\/]node_modules[\\/](aframe|cannon|three\.js))/,
           priority: 100,
           name: "engine",
           chunks: "all"
@@ -180,7 +180,7 @@ module.exports = (env, argv) => ({
     new HTMLWebpackPlugin({
       filename: "index.html",
       template: path.join(__dirname, "src", "index.html"),
-      chunks: ["vendor", "engine", "index"]
+      chunks: ["vendor", "index"]
     }),
     new HTMLWebpackPlugin({
       filename: "hub.html",