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/package-lock.json b/package-lock.json
index 17b5b9ac2b9e54c87e47c0e8fb73460a7ef4c06c..89f73fe4c6e6fbecf2d06e51f2c27b72284ae991 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -888,7 +888,7 @@
     },
     "assert-plus": {
       "version": "1.0.0",
-      "resolved": "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz",
+      "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
       "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=",
       "dev": true
     },
@@ -917,13 +917,13 @@
     },
     "async-foreach": {
       "version": "0.1.3",
-      "resolved": "https://registry.yarnpkg.com/async-foreach/-/async-foreach-0.1.3.tgz",
+      "resolved": "https://registry.npmjs.org/async-foreach/-/async-foreach-0.1.3.tgz",
       "integrity": "sha1-NhIfhFwFeBct5Bmpfb6x0W7DRUI=",
       "dev": true
     },
     "asynckit": {
       "version": "0.4.0",
-      "resolved": "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
       "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=",
       "dev": true
     },
@@ -979,7 +979,7 @@
     },
     "aws-sign2": {
       "version": "0.7.0",
-      "resolved": "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz",
+      "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",
       "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=",
       "dev": true
     },
@@ -2121,7 +2121,7 @@
     },
     "block-stream": {
       "version": "0.0.9",
-      "resolved": "https://registry.yarnpkg.com/block-stream/-/block-stream-0.0.9.tgz",
+      "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz",
       "integrity": "sha1-E+v+d4oDIFz+A3UUgeu0szAMEmo=",
       "dev": true,
       "requires": {
@@ -2556,7 +2556,7 @@
     },
     "camelcase-keys": {
       "version": "2.1.0",
-      "resolved": "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-2.1.0.tgz",
+      "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz",
       "integrity": "sha1-MIvur/3ygRkFHvodkyITyRuPkuc=",
       "dev": true,
       "requires": {
@@ -2586,7 +2586,7 @@
     },
     "caseless": {
       "version": "0.12.0",
-      "resolved": "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz",
+      "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
       "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=",
       "dev": true
     },
@@ -2954,7 +2954,7 @@
     },
     "co": {
       "version": "4.6.0",
-      "resolved": "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz",
+      "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
       "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=",
       "dev": true
     },
@@ -3002,7 +3002,7 @@
     },
     "combined-stream": {
       "version": "1.0.6",
-      "resolved": "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.6.tgz",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.6.tgz",
       "integrity": "sha1-cj599ugBrFYTETp+RFqbactjKBg=",
       "dev": true,
       "requires": {
@@ -3176,7 +3176,7 @@
     },
     "console-control-strings": {
       "version": "1.1.0",
-      "resolved": "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz",
+      "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
       "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=",
       "dev": true
     },
@@ -3521,7 +3521,7 @@
     },
     "dashdash": {
       "version": "1.14.1",
-      "resolved": "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz",
+      "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
       "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=",
       "dev": true,
       "requires": {
@@ -3763,7 +3763,7 @@
     },
     "delayed-stream": {
       "version": "1.0.0",
-      "resolved": "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
       "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=",
       "dev": true
     },
@@ -3774,7 +3774,7 @@
     },
     "delegates": {
       "version": "1.0.0",
-      "resolved": "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz",
+      "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
       "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=",
       "dev": true
     },
@@ -4871,7 +4871,7 @@
     },
     "extsprintf": {
       "version": "1.3.0",
-      "resolved": "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz",
+      "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",
       "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=",
       "dev": true
     },
@@ -5300,13 +5300,13 @@
     },
     "forever-agent": {
       "version": "0.6.1",
-      "resolved": "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz",
+      "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
       "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=",
       "dev": true
     },
     "form-data": {
       "version": "2.3.2",
-      "resolved": "https://registry.yarnpkg.com/form-data/-/form-data-2.3.2.tgz",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.2.tgz",
       "integrity": "sha1-SXBJi+YEwgwAXU9cI67NIda0kJk=",
       "dev": true,
       "requires": {
@@ -5837,7 +5837,7 @@
     },
     "fstream": {
       "version": "1.0.11",
-      "resolved": "https://registry.yarnpkg.com/fstream/-/fstream-1.0.11.tgz",
+      "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.11.tgz",
       "integrity": "sha1-XB+x8RdHcRTwYyoOtLcbPLD9MXE=",
       "dev": true,
       "requires": {
@@ -5861,7 +5861,7 @@
     },
     "gauge": {
       "version": "2.7.4",
-      "resolved": "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz",
+      "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz",
       "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=",
       "dev": true,
       "requires": {
@@ -5877,7 +5877,7 @@
       "dependencies": {
         "is-fullwidth-code-point": {
           "version": "1.0.0",
-          "resolved": "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
+          "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
           "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
           "dev": true,
           "requires": {
@@ -5886,7 +5886,7 @@
         },
         "string-width": {
           "version": "1.0.2",
-          "resolved": "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz",
+          "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
           "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
           "dev": true,
           "requires": {
@@ -5914,7 +5914,7 @@
     },
     "get-stdin": {
       "version": "4.0.1",
-      "resolved": "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz",
+      "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz",
       "integrity": "sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4=",
       "dev": true
     },
@@ -5932,7 +5932,7 @@
     },
     "getpass": {
       "version": "0.1.7",
-      "resolved": "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz",
+      "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz",
       "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=",
       "dev": true,
       "requires": {
@@ -6258,13 +6258,13 @@
     },
     "har-schema": {
       "version": "2.0.0",
-      "resolved": "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz",
+      "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz",
       "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=",
       "dev": true
     },
     "har-validator": {
       "version": "5.0.3",
-      "resolved": "https://registry.yarnpkg.com/har-validator/-/har-validator-5.0.3.tgz",
+      "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.0.3.tgz",
       "integrity": "sha1-ukAsJmGU8VlW7xXg/PJCmT9qff0=",
       "dev": true,
       "requires": {
@@ -6274,7 +6274,7 @@
       "dependencies": {
         "ajv": {
           "version": "5.5.2",
-          "resolved": "https://registry.yarnpkg.com/ajv/-/ajv-5.5.2.tgz",
+          "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz",
           "integrity": "sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU=",
           "dev": true,
           "requires": {
@@ -6286,13 +6286,13 @@
         },
         "fast-deep-equal": {
           "version": "1.1.0",
-          "resolved": "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz",
+          "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz",
           "integrity": "sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ=",
           "dev": true
         },
         "json-schema-traverse": {
           "version": "0.3.1",
-          "resolved": "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz",
+          "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz",
           "integrity": "sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A=",
           "dev": true
         }
@@ -6364,7 +6364,7 @@
     },
     "has-unicode": {
       "version": "2.0.1",
-      "resolved": "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz",
+      "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
       "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=",
       "dev": true
     },
@@ -6695,7 +6695,7 @@
     },
     "http-signature": {
       "version": "1.2.0",
-      "resolved": "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz",
+      "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz",
       "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=",
       "dev": true,
       "requires": {
@@ -6785,7 +6785,7 @@
     },
     "in-publish": {
       "version": "2.0.0",
-      "resolved": "https://registry.yarnpkg.com/in-publish/-/in-publish-2.0.0.tgz",
+      "resolved": "https://registry.npmjs.org/in-publish/-/in-publish-2.0.0.tgz",
       "integrity": "sha1-4g/146KvwmkDILbcVSaCqcf631E=",
       "dev": true
     },
@@ -7317,7 +7317,7 @@
     },
     "is-typedarray": {
       "version": "1.0.0",
-      "resolved": "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz",
+      "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
       "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=",
       "dev": true
     },
@@ -7383,7 +7383,7 @@
     },
     "isstream": {
       "version": "0.1.2",
-      "resolved": "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz",
+      "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
       "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=",
       "dev": true
     },
@@ -7598,7 +7598,7 @@
     },
     "json-schema": {
       "version": "0.2.3",
-      "resolved": "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz",
+      "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz",
       "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=",
       "dev": true
     },
@@ -7616,7 +7616,7 @@
     },
     "json-stringify-safe": {
       "version": "5.0.1",
-      "resolved": "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
+      "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
       "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=",
       "dev": true
     },
@@ -7638,7 +7638,7 @@
     },
     "jsprim": {
       "version": "1.4.1",
-      "resolved": "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz",
+      "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz",
       "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=",
       "dev": true,
       "requires": {
@@ -8136,7 +8136,7 @@
     },
     "load-json-file": {
       "version": "1.1.0",
-      "resolved": "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz",
+      "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz",
       "integrity": "sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=",
       "dev": true,
       "requires": {
@@ -8149,7 +8149,7 @@
       "dependencies": {
         "pify": {
           "version": "2.3.0",
-          "resolved": "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz",
+          "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
           "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=",
           "dev": true
         }
@@ -8198,7 +8198,7 @@
     },
     "lodash.assign": {
       "version": "4.2.0",
-      "resolved": "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-4.2.0.tgz",
+      "resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-4.2.0.tgz",
       "integrity": "sha1-DZnzzNem0mHRm9rrkkUAXShYCOc=",
       "dev": true
     },
@@ -8210,7 +8210,7 @@
     },
     "lodash.clonedeep": {
       "version": "4.5.0",
-      "resolved": "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
+      "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
       "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=",
       "dev": true
     },
@@ -8543,7 +8543,7 @@
     },
     "meow": {
       "version": "3.7.0",
-      "resolved": "https://registry.yarnpkg.com/meow/-/meow-3.7.0.tgz",
+      "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz",
       "integrity": "sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=",
       "dev": true,
       "requires": {
@@ -8859,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",
@@ -9043,7 +9043,7 @@
       "dependencies": {
         "semver": {
           "version": "5.3.0",
-          "resolved": "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz",
           "integrity": "sha1-myzl094C0XxgEq0yaqa00M9U+U8=",
           "dev": true
         }
@@ -9129,13 +9129,13 @@
       "dependencies": {
         "ansi-styles": {
           "version": "2.2.1",
-          "resolved": "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz",
+          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
           "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=",
           "dev": true
         },
         "chalk": {
           "version": "1.1.3",
-          "resolved": "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz",
+          "resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
           "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
           "dev": true,
           "requires": {
@@ -9148,7 +9148,7 @@
         },
         "cross-spawn": {
           "version": "3.0.1",
-          "resolved": "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-3.0.1.tgz",
+          "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-3.0.1.tgz",
           "integrity": "sha1-ElYDfsufDF9549bvE14wdwGEuYI=",
           "dev": true,
           "requires": {
@@ -9158,7 +9158,7 @@
         },
         "supports-color": {
           "version": "2.0.0",
-          "resolved": "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
           "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=",
           "dev": true
         }
@@ -9207,7 +9207,7 @@
     },
     "nopt": {
       "version": "3.0.6",
-      "resolved": "https://registry.yarnpkg.com/nopt/-/nopt-3.0.6.tgz",
+      "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz",
       "integrity": "sha1-xkZdvwirzU2zWTF/eaxopkayj/k=",
       "dev": true,
       "requires": {
@@ -9306,7 +9306,7 @@
     },
     "oauth-sign": {
       "version": "0.8.2",
-      "resolved": "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz",
+      "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz",
       "integrity": "sha1-Rqarfwrq2N6unsBWV4C31O/rnUM=",
       "dev": true
     },
@@ -9723,7 +9723,7 @@
     },
     "parse-json": {
       "version": "2.2.0",
-      "resolved": "https://registry.yarnpkg.com/parse-json/-/parse-json-2.2.0.tgz",
+      "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz",
       "integrity": "sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=",
       "dev": true,
       "requires": {
@@ -9790,7 +9790,7 @@
     },
     "path-exists": {
       "version": "2.1.0",
-      "resolved": "https://registry.yarnpkg.com/path-exists/-/path-exists-2.1.0.tgz",
+      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz",
       "integrity": "sha1-D+tsZPD8UY2adU3V77YscCJ2H0s=",
       "dev": true,
       "requires": {
@@ -9850,7 +9850,7 @@
     },
     "performance-now": {
       "version": "2.1.0",
-      "resolved": "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz",
+      "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
       "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=",
       "dev": true
     },
@@ -10587,7 +10587,7 @@
     },
     "read-pkg": {
       "version": "1.1.0",
-      "resolved": "https://registry.yarnpkg.com/read-pkg/-/read-pkg-1.1.0.tgz",
+      "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz",
       "integrity": "sha1-9f+qXs0pyzHAR0vKfXVra7KePyg=",
       "dev": true,
       "requires": {
@@ -10598,7 +10598,7 @@
       "dependencies": {
         "path-type": {
           "version": "1.1.0",
-          "resolved": "https://registry.yarnpkg.com/path-type/-/path-type-1.1.0.tgz",
+          "resolved": "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz",
           "integrity": "sha1-WcRPfuSR2nBNpBXaWkBwuk+P5EE=",
           "dev": true,
           "requires": {
@@ -10609,7 +10609,7 @@
         },
         "pify": {
           "version": "2.3.0",
-          "resolved": "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz",
+          "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
           "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=",
           "dev": true
         }
@@ -10617,7 +10617,7 @@
     },
     "read-pkg-up": {
       "version": "1.0.1",
-      "resolved": "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz",
+      "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz",
       "integrity": "sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI=",
       "dev": true,
       "requires": {
@@ -10627,7 +10627,7 @@
       "dependencies": {
         "find-up": {
           "version": "1.1.2",
-          "resolved": "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz",
+          "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz",
           "integrity": "sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8=",
           "dev": true,
           "requires": {
@@ -10708,7 +10708,7 @@
     },
     "redent": {
       "version": "1.0.0",
-      "resolved": "https://registry.yarnpkg.com/redent/-/redent-1.0.0.tgz",
+      "resolved": "https://registry.npmjs.org/redent/-/redent-1.0.0.tgz",
       "integrity": "sha1-z5Fqsf1fHxbfsggi3W7H9zDCr94=",
       "dev": true,
       "requires": {
@@ -11178,7 +11178,7 @@
     },
     "sass-graph": {
       "version": "2.2.4",
-      "resolved": "https://registry.yarnpkg.com/sass-graph/-/sass-graph-2.2.4.tgz",
+      "resolved": "https://registry.npmjs.org/sass-graph/-/sass-graph-2.2.4.tgz",
       "integrity": "sha1-E/vWPNHK8JCLn9k0dq1DpR0eC0k=",
       "dev": true,
       "requires": {
@@ -11196,7 +11196,7 @@
         },
         "cliui": {
           "version": "3.2.0",
-          "resolved": "https://registry.yarnpkg.com/cliui/-/cliui-3.2.0.tgz",
+          "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz",
           "integrity": "sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0=",
           "dev": true,
           "requires": {
@@ -11207,7 +11207,7 @@
         },
         "is-fullwidth-code-point": {
           "version": "1.0.0",
-          "resolved": "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
+          "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
           "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
           "dev": true,
           "requires": {
@@ -11216,7 +11216,7 @@
         },
         "os-locale": {
           "version": "1.4.0",
-          "resolved": "https://registry.yarnpkg.com/os-locale/-/os-locale-1.4.0.tgz",
+          "resolved": "http://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz",
           "integrity": "sha1-IPnxeuKe00XoveWDsT0gCYA8FNk=",
           "dev": true,
           "requires": {
@@ -11225,7 +11225,7 @@
         },
         "string-width": {
           "version": "1.0.2",
-          "resolved": "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz",
+          "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
           "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
           "dev": true,
           "requires": {
@@ -11236,13 +11236,13 @@
         },
         "which-module": {
           "version": "1.0.0",
-          "resolved": "https://registry.yarnpkg.com/which-module/-/which-module-1.0.0.tgz",
+          "resolved": "https://registry.npmjs.org/which-module/-/which-module-1.0.0.tgz",
           "integrity": "sha1-u6Y8qGGUiZT/MHc2CJ47lgJsKk8=",
           "dev": true
         },
         "yargs": {
           "version": "7.1.0",
-          "resolved": "https://registry.yarnpkg.com/yargs/-/yargs-7.1.0.tgz",
+          "resolved": "https://registry.npmjs.org/yargs/-/yargs-7.1.0.tgz",
           "integrity": "sha1-a6MY6xaWFyf10oT46gA+jWFU0Mg=",
           "dev": true,
           "requires": {
@@ -11263,7 +11263,7 @@
         },
         "yargs-parser": {
           "version": "5.0.0",
-          "resolved": "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-5.0.0.tgz",
+          "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-5.0.0.tgz",
           "integrity": "sha1-J17PDX/+Bcd+ZOfIbkzZS/DhIoo=",
           "dev": true,
           "requires": {
@@ -11313,7 +11313,7 @@
     },
     "scss-tokenizer": {
       "version": "0.2.3",
-      "resolved": "https://registry.yarnpkg.com/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz",
+      "resolved": "https://registry.npmjs.org/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz",
       "integrity": "sha1-jrBtualyMzOCTT9VMGQRSYR85dE=",
       "dev": true,
       "requires": {
@@ -11323,7 +11323,7 @@
       "dependencies": {
         "source-map": {
           "version": "0.4.4",
-          "resolved": "https://registry.yarnpkg.com/source-map/-/source-map-0.4.4.tgz",
+          "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz",
           "integrity": "sha1-66T12pwNyZneaAMti092FzZSA2s=",
           "dev": true,
           "requires": {
@@ -11918,7 +11918,7 @@
     },
     "sshpk": {
       "version": "1.14.2",
-      "resolved": "https://registry.yarnpkg.com/sshpk/-/sshpk-1.14.2.tgz",
+      "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.14.2.tgz",
       "integrity": "sha1-xvxhZIo9nE52T9P8306hBeSSupg=",
       "dev": true,
       "requires": {
@@ -12115,7 +12115,7 @@
     },
     "strip-indent": {
       "version": "1.0.1",
-      "resolved": "https://registry.yarnpkg.com/strip-indent/-/strip-indent-1.0.1.tgz",
+      "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-1.0.1.tgz",
       "integrity": "sha1-DHlipq3vp7vUrDZkYKY4VSrhoKI=",
       "dev": true,
       "requires": {
@@ -12518,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",
@@ -12564,7 +12564,7 @@
     },
     "tar": {
       "version": "2.2.1",
-      "resolved": "https://registry.yarnpkg.com/tar/-/tar-2.2.1.tgz",
+      "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.1.tgz",
       "integrity": "sha1-jk0qJWwOIYXGsYrWlK7JaLg8sdE=",
       "dev": true,
       "requires": {
@@ -12793,7 +12793,7 @@
     },
     "trim-newlines": {
       "version": "1.0.0",
-      "resolved": "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz",
+      "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz",
       "integrity": "sha1-WIeWa7WCpFA6QetST301ARgVphM=",
       "dev": true
     },
@@ -12838,7 +12838,7 @@
     },
     "tunnel-agent": {
       "version": "0.6.0",
-      "resolved": "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+      "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
       "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=",
       "dev": true,
       "requires": {
@@ -13323,7 +13323,7 @@
     },
     "verror": {
       "version": "1.10.0",
-      "resolved": "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz",
+      "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
       "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=",
       "dev": true,
       "requires": {
diff --git a/package.json b/package.json
index 4599d553834ba4a3a993382e74d973262e2e5ecc..b0f7710f6b8bdcf1c4606b5ca7bc5e44bf815e90 100644
--- a/package.json
+++ b/package.json
@@ -45,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",
@@ -54,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/stylesheets/2d-hud.scss b/src/assets/stylesheets/2d-hud.scss
index ca0a87c337153e683a0a08964e02c071f0941f62..f8b56e42545b757b425135a2e7fb1a21abf29a62 100644
--- a/src/assets/stylesheets/2d-hud.scss
+++ b/src/assets/stylesheets/2d-hud.scss
@@ -113,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);
 }
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 a5080fe24de9e24302d8c971549d090f4d15ef1b..625fc4c3536a1660b4a908d4d8d6e47e36377f44 100644
--- a/src/components/gltf-model-plus.js
+++ b/src/components/gltf-model-plus.js
@@ -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/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 0c69e82da5cfe8c62e4aabafd2506fcf11613e3d..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).entity;
+    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/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 0d5e2837d3d6d36ec116ba2d01baecaa96f2cc4a..6bc7382c84b8ec3cf3af71914a592ca853f76d1b 100644
--- a/src/hub.js
+++ b/src/hub.js
@@ -127,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";
@@ -140,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");
@@ -319,8 +326,9 @@ const onReady = async () => {
     });
 
     const offset = { x: 0, y: 0, z: -1.5 };
+
     const spawnMediaInfrontOfPlayer = (src, contentOrigin) => {
-      const { entity, orientation } = addMedia(src, contentOrigin, true);
+      const { entity, orientation } = addMedia(src, "#interactable-media", contentOrigin, true);
 
       orientation.then(or => {
         entity.setAttribute("offset-relative-to", {
@@ -553,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 c2db55e5f8b953b8597f79915f645d96f4270b83..052817a81d6aac00a1b2881df7eb73a5268fc79c 100644
--- a/src/react-components/2d-hud.js
+++ b/src/react-components/2d-hud.js
@@ -4,7 +4,7 @@ import cx from "classnames";
 
 import styles from "../assets/stylesheets/2d-hud.scss";
 
-const TopHUD = ({ muted, frozen, spacebubble, onToggleMute, onToggleFreeze, onToggleSpaceBubble }) => (
+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
@@ -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,10 +27,9 @@ 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, showPhotoPicker, onMediaPicked }) => (
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 c4e922321384825b1c9eee618a1e1a5f5ffa70cc..2636230326838dc4bcabbb73e8d38a8674b2f4da 100644
--- a/src/react-components/create-object-dialog.js
+++ b/src/react-components/create-object-dialog.js
@@ -1,5 +1,4 @@
 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";
@@ -7,6 +6,7 @@ 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,
@@ -36,8 +36,8 @@ export default class CreateObjectDialog extends Component {
   };
 
   static propTypes = {
-    onCreateObject: PropTypes.func,
-    onCloseDialog: PropTypes.func
+    onCreate: PropTypes.func,
+    onClose: PropTypes.func
   };
 
   componentDidMount() {
@@ -67,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({
@@ -83,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} />
@@ -106,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 4c2a9ffdfda1af43ee6848b7955fc6752684e3fe..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 = media => {
+  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,9 +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.handleCreateObject}
+                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 825865dcac289bc2afa1f8f3ed878b485e682ee1..829d00de9709d3d2ce5214c884edf63b1dac6a35 100644
--- a/src/utils/media-utils.js
+++ b/src/utils/media-utils.js
@@ -91,12 +91,12 @@ function getOrientation(file, callback) {
 }
 
 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);
 
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/webpack.config.js b/webpack.config.js
index 966ca07936629936d3fa1ca1efa3f88eb1e4078b..9531ceb26e70881f4bd4b5af7eb4367e2141f007 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -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",