diff --git a/package-lock.json b/package-lock.json
index b71dbe9e1608560a4162ddb75d111bbda7e427ac..e86a9d3b62f005678908a1a9c21432007fc42f85 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -659,6 +659,11 @@
       "resolved": "https://registry.npmjs.org/an-array/-/an-array-1.0.0.tgz",
       "integrity": "sha1-wSWlu4JXd4419LT2qpx9D6nkJmU="
     },
+    "animejs": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/animejs/-/animejs-2.2.0.tgz",
+      "integrity": "sha1-Ne79/FNbgZScnLBvCz5gwC5v3IA="
+    },
     "ansi-colors": {
       "version": "3.0.5",
       "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-3.0.5.tgz",
diff --git a/package.json b/package.json
index 40a1584087063e2ddac674079394694a76783491..55b8f2ad4fb44f4ec92a3dbfe8f55adfbdbf4de3 100644
--- a/package.json
+++ b/package.json
@@ -35,6 +35,7 @@
     "aframe-rounded": "^1.0.3",
     "aframe-slice9-component": "^1.0.0",
     "aframe-teleport-controls": "github:mozillareality/aframe-teleport-controls#hubs/master",
+    "animejs": "^2.2.0",
     "classnames": "^2.2.5",
     "copy-to-clipboard": "^3.0.8",
     "deepmerge": "^2.1.1",
diff --git a/src/assets/hud/presence-count.png b/src/assets/hud/presence-count.png
new file mode 100755
index 0000000000000000000000000000000000000000..7efe68711cdf61d0501f1dcef77d8f20b9f7272a
Binary files /dev/null and b/src/assets/hud/presence-count.png differ
diff --git a/src/assets/sfx/Chiptone_Settings/settings_SpawnBuildup.cpt b/src/assets/sfx/Chiptone_Settings/settings_SpawnBuildup.cpt
new file mode 100755
index 0000000000000000000000000000000000000000..fb72bb1c0cdb868587f492a8862cc9f0d3cef85d
Binary files /dev/null and b/src/assets/sfx/Chiptone_Settings/settings_SpawnBuildup.cpt differ
diff --git a/src/assets/sfx/Chiptone_Settings/settings_TeleportArc.cpt b/src/assets/sfx/Chiptone_Settings/settings_TeleportArc.cpt
new file mode 100755
index 0000000000000000000000000000000000000000..5d3430538890207b67d52f1555e747c2ab0186c6
Binary files /dev/null and b/src/assets/sfx/Chiptone_Settings/settings_TeleportArc.cpt differ
diff --git a/src/assets/sfx/Chiptone_Settings/settings_tick.cpt b/src/assets/sfx/Chiptone_Settings/settings_tick.cpt
new file mode 100755
index 0000000000000000000000000000000000000000..4135f4a621b44b84fde074dc40ac5f3341b6ae89
Binary files /dev/null and b/src/assets/sfx/Chiptone_Settings/settings_tick.cpt differ
diff --git a/src/assets/sfx/Eb_blip.wav b/src/assets/sfx/Eb_blip.wav
index 7428237df05a6655ba8a01a88878d43045967794..c8e8194d21e133ee2fb19b2ddc3e3b372cd89c4b 100644
Binary files a/src/assets/sfx/Eb_blip.wav and b/src/assets/sfx/Eb_blip.wav differ
diff --git a/src/assets/sfx/pop.wav b/src/assets/sfx/pop.wav
new file mode 100755
index 0000000000000000000000000000000000000000..b0fc22210c5114f8cc786e09d5816750cc667063
Binary files /dev/null and b/src/assets/sfx/pop.wav differ
diff --git a/src/assets/sfx/suspense.wav b/src/assets/sfx/suspense.wav
new file mode 100755
index 0000000000000000000000000000000000000000..3b9043984412fe96c8949a6927a051f66738e50b
Binary files /dev/null and b/src/assets/sfx/suspense.wav differ
diff --git a/src/assets/sfx/tack.wav b/src/assets/sfx/tack.wav
new file mode 100755
index 0000000000000000000000000000000000000000..1f16efbdbbcf02548c16a4803da7f329948b4bec
Binary files /dev/null and b/src/assets/sfx/tack.wav differ
diff --git a/src/assets/sfx/teleportArc.wav b/src/assets/sfx/teleportArc.wav
new file mode 100755
index 0000000000000000000000000000000000000000..cfedc3100094cc377ba7a9a0acb3c965a043819f
Binary files /dev/null and b/src/assets/sfx/teleportArc.wav differ
diff --git a/src/assets/sfx/tick.wav b/src/assets/sfx/tick.wav
new file mode 100755
index 0000000000000000000000000000000000000000..c91e1b66c27a96bfcecdd95df64982f7209a66ca
Binary files /dev/null and b/src/assets/sfx/tick.wav differ
diff --git a/src/assets/sfx/welcome.wav b/src/assets/sfx/welcome.wav
new file mode 100755
index 0000000000000000000000000000000000000000..9a85364db26dc7761926170547704ec023cc8f31
Binary files /dev/null and b/src/assets/sfx/welcome.wav differ
diff --git a/src/assets/stylesheets/chat-command-help.scss b/src/assets/stylesheets/chat-command-help.scss
new file mode 100644
index 0000000000000000000000000000000000000000..c8f0b42a5eedcbeaff94f06065cd87676b6f2aad
--- /dev/null
+++ b/src/assets/stylesheets/chat-command-help.scss
@@ -0,0 +1,30 @@
+@import 'shared.scss';
+
+:local(.command-help) {
+  background-color: $darker-grey;
+  color: $light-text;
+  position: absolute;
+  display: flex;
+  flex-direction: column;
+  width: 75%;
+  left: 0;
+  bottom: 58px;
+  pointer-events: auto;
+  padding: 8px 1.25em;
+  border-radius: 16px;
+  font-size: 0.8em;
+
+  :local(.entry) {
+    @extend %default-font;
+    margin: 4px;
+
+    display: flex;
+    justify-content: space-between;
+
+    :local(.command) {
+      font-weight: bold;
+      white-space: nowrap;
+      margin-right: 8px;
+    }
+  }
+}
diff --git a/src/assets/stylesheets/spoke.scss b/src/assets/stylesheets/spoke.scss
index 0219502e7a00014e7cbb07530ca3a78e06353b73..c253c221c30fd0878be3e31750f5feaacc2c6607 100644
--- a/src/assets/stylesheets/spoke.scss
+++ b/src/assets/stylesheets/spoke.scss
@@ -166,10 +166,17 @@ body {
   @extend %action-button;
   background-color: $darker-grey;
   margin: auto;
-  margin-top: 64px;
   padding: 0px 82px;
 }
 
+:local(.tutorial-buttons) {
+  margin-top: 64px;
+
+  button {
+    margin: 32px auto;
+  }
+}
+
 :local(.close-video) {
   margin-top: 12px;
 }
diff --git a/src/assets/translations.data.json b/src/assets/translations.data.json
index 819a5a04649cf73d1cc12f9d8b64f6cd1b5ababd..643897d544fcb7e992c4263356d00fca979d0151 100644
--- a/src/assets/translations.data.json
+++ b/src/assets/translations.data.json
@@ -51,6 +51,7 @@
     "audio.granted-next": "Next",
     "exit.subtitle.exited": "Your session has ended. Refresh your browser to start a new one.",
     "exit.subtitle.closed": "This room is no longer available.",
+    "exit.subtitle.left": "You have left the room.",
     "exit.subtitle.full": "This room is full, please try again later.",
     "exit.subtitle.connect_error": "Unable to connect to this room, please try again later.",
     "exit.subtitle.version_mismatch": "The version you deployed is not available yet. Your browser will refresh in 5 seconds.",
@@ -59,6 +60,7 @@
     "autoexit.subtitle": "You have started another session.",
     "autoexit.cancel": "CANCEL",
     "presence.entered_room": "entered the room.",
+    "presence.entered_lobby": "entered the lobby.",
     "presence.join_lobby": "joined the lobby.",
     "presence.leave": "left.",
     "presence.name_change": "is now known as",
@@ -110,6 +112,14 @@
     "spoke.download_unsupported": "View Releases",
     "spoke.browse_all_versions": "Browse All Versions",
     "spoke.close": "Close",
-    "spoke.play_button": "Learn Spoke in 5 Minutes"
+    "spoke.beginner_tutorial_button": "Learn Spoke in 5 Minutes",
+    "spoke.advanced_tutorial_button": "Advanced Spoke in 10 minutes",
+    "spoke.play_button": "Learn Spoke in 5 Minutes",
+    "commands.fly": "Toggle fly mode.",
+    "commands.bigger": "Increase your avatar's size.",
+    "commands.smaller": "Decrease your avatar's size.",
+    "commands.help": "Show help.",
+    "commands.leave": "Disconnect from the room.",
+    "commands.duck": "The duck tested well. Quack."
   }
 }
diff --git a/src/components/animation.js b/src/components/animation.js
new file mode 100644
index 0000000000000000000000000000000000000000..f0a709b9565a61b2e1a8c66d896fe336fb3ae471
--- /dev/null
+++ b/src/components/animation.js
@@ -0,0 +1,643 @@
+// Taken from A-Frame 0.9.0 master, TODO remove
+
+const anime = require("animejs");
+const components = AFRAME.components;
+const registerComponent = AFRAME.registerComponent;
+const utils = AFRAME.utils;
+
+const colorHelperFrom = new THREE.Color();
+const colorHelperTo = new THREE.Color();
+
+const getComponentProperty = utils.entity.getComponentProperty;
+const setComponentProperty = utils.entity.setComponentProperty;
+const splitCache = {};
+
+const TYPE_COLOR = "color";
+const PROP_POSITION = "position";
+const PROP_ROTATION = "rotation";
+const PROP_SCALE = "scale";
+const STRING_COMPONENTS = "components";
+const STRING_OBJECT3D = "object3D";
+
+/**
+ * Given property name, check schema to see what type we are animating.
+ * We just care whether the property is a vector.
+ */
+function getPropertyType(el, property) {
+  const split = property.split(".");
+  const componentName = split[0];
+  const propertyName = split[1];
+  const component = el.components[componentName] || components[componentName];
+
+  // Primitives.
+  if (!component) {
+    return null;
+  }
+
+  // Dynamic schema. We only care about vectors anyways.
+  if (propertyName && !component.schema[propertyName]) {
+    return null;
+  }
+
+  // Multi-prop.
+  if (propertyName) {
+    return component.schema[propertyName].type;
+  }
+
+  // Single-prop.
+  return component.schema.type;
+}
+
+/**
+ * Convert object to radians.
+ */
+function toRadians(obj) {
+  obj.x = THREE.Math.degToRad(obj.x);
+  obj.y = THREE.Math.degToRad(obj.y);
+  obj.z = THREE.Math.degToRad(obj.z);
+}
+
+function addEventListeners(el, eventNames, handler) {
+  let i;
+  for (i = 0; i < eventNames.length; i++) {
+    el.addEventListener(eventNames[i], handler);
+  }
+}
+
+function removeEventListeners(el, eventNames, handler) {
+  let i;
+  for (i = 0; i < eventNames.length; i++) {
+    el.removeEventListener(eventNames[i], handler);
+  }
+}
+
+function splitDot(path) {
+  if (path in splitCache) {
+    return splitCache[path];
+  }
+  splitCache[path] = path.split(".");
+  return splitCache[path];
+}
+
+function getRawProperty(el, path) {
+  let i;
+  let value;
+  const split = splitDot(path);
+  value = el;
+  for (i = 0; i < split.length; i++) {
+    value = value[split[i]];
+  }
+  return value;
+}
+
+function setRawProperty(el, path, value, type) {
+  let i;
+
+  if (path.startsWith("object3D.rotation")) {
+    value = THREE.Math.degToRad(value);
+  }
+
+  // Walk.
+  const split = splitDot(path);
+  let targetValue = el;
+  for (i = 0; i < split.length - 1; i++) {
+    targetValue = targetValue[split[i]];
+  }
+  const propertyName = split[split.length - 1];
+
+  // Raw color.
+  if (type === TYPE_COLOR) {
+    if ("r" in targetValue[propertyName]) {
+      targetValue[propertyName].r = value.r;
+      targetValue[propertyName].g = value.g;
+      targetValue[propertyName].b = value.b;
+    } else {
+      targetValue[propertyName].x = value.r;
+      targetValue[propertyName].y = value.g;
+      targetValue[propertyName].z = value.b;
+    }
+    return;
+  }
+
+  targetValue[propertyName] = value;
+}
+
+function isRawProperty(data) {
+  return data.isRawProperty || data.property.startsWith(STRING_COMPONENTS) || data.property.startsWith(STRING_OBJECT3D);
+}
+/**
+ * Animation component for A-Frame using anime.js.
+ *
+ * The component manually controls the tick by setting `autoplay: false` on anime.js and
+ * manually * calling `animation.tick()` in the tick handler. To pause or resume, we toggle a
+ * boolean * flag * `isAnimationPlaying`.
+ *
+ * anime.js animation config for tweenining Javascript objects and values works as:
+ *
+ *  config = {
+ *    targets: {foo: 0.0, bar: '#000'},
+ *    foo: 1.0,
+ *    bar: '#FFF'
+ *  }
+ *
+ * The above will tween each property in `targets`. The `to` values are set in the root of
+ * the config.
+ *
+ * @member {object} animation - anime.js instance.
+ * @member {boolean} animationIsPlaying - Control if animation is playing.
+ */
+module.exports.Component = registerComponent("animation", {
+  schema: {
+    autoplay: { default: true },
+    delay: { default: 0 },
+    dir: { default: "" },
+    dur: { default: 1000 },
+    easing: { default: "easeInQuad" },
+    elasticity: { default: 400 },
+    enabled: { default: true },
+    from: { default: "" },
+    loop: {
+      default: 0,
+      parse: function(value) {
+        // Boolean or integer.
+        if (value === true || value === "true") {
+          return true;
+        }
+        if (value === false || value === "false") {
+          return false;
+        }
+        return parseInt(value, 10);
+      }
+    },
+    property: { default: "" },
+    startEvents: { type: "array" },
+    pauseEvents: { type: "array" },
+    resumeEvents: { type: "array" },
+    round: { default: false },
+    to: { default: "" },
+    type: { default: "" },
+    isRawProperty: { default: false }
+  },
+
+  multiple: true,
+
+  init: function() {
+    const self = this;
+
+    this.eventDetail = { name: this.attrName };
+    this.time = 0;
+
+    this.animation = null;
+    this.animationIsPlaying = false;
+    this.onStartEvent = this.onStartEvent.bind(this);
+    this.beginAnimation = this.beginAnimation.bind(this);
+    this.pauseAnimation = this.pauseAnimation.bind(this);
+    this.resumeAnimation = this.resumeAnimation.bind(this);
+
+    this.fromColor = {};
+    this.toColor = {};
+    this.targets = {};
+    this.targetsArray = [];
+
+    this.updateConfigForDefault = this.updateConfigForDefault.bind(this);
+    this.updateConfigForRawColor = this.updateConfigForRawColor.bind(this);
+
+    this.config = {
+      complete: function() {
+        self.animationIsPlaying = false;
+        self.el.emit("animationcomplete", self.eventDetail, false);
+        if (self.id) {
+          self.el.emit("animationcomplete__" + self.id, self.eventDetail, false);
+        }
+      }
+    };
+  },
+
+  update: function(oldData) {
+    const config = this.config;
+    const data = this.data;
+
+    this.animationIsPlaying = false;
+
+    if (oldData.enabled && !this.data.enabled) {
+      return;
+    }
+
+    if (!data.property) {
+      return;
+    }
+
+    // Base config.
+    config.autoplay = false;
+    config.direction = data.dir;
+    config.duration = data.dur;
+    config.easing = data.easing;
+    config.elasticity = data.elasticity;
+    config.loop = data.loop;
+    config.round = data.round;
+
+    // Start new animation.
+    this.createAndStartAnimation();
+  },
+
+  tick: function(t, dt) {
+    if (!this.animationIsPlaying) {
+      return;
+    }
+    this.time += dt;
+    this.animation.tick(this.time);
+  },
+
+  remove: function() {
+    this.pauseAnimation();
+    this.removeEventListeners();
+  },
+
+  pause: function() {
+    this.paused = true;
+    this.pausedWasPlaying = true;
+    this.pauseAnimation();
+    this.removeEventListeners();
+  },
+
+  /**
+   * `play` handler only for resuming scene.
+   */
+  play: function() {
+    if (!this.paused) {
+      return;
+    }
+    this.paused = false;
+    this.addEventListeners();
+    if (this.pausedWasPlaying) {
+      this.resumeAnimation();
+      this.pausedWasPlaying = false;
+    }
+  },
+
+  /**
+   * Start animation from scratch.
+   */
+  createAndStartAnimation: function() {
+    const data = this.data;
+
+    this.updateConfig();
+    this.animationIsPlaying = false;
+    this.animation = anime(this.config);
+
+    this.removeEventListeners();
+    this.addEventListeners();
+
+    // Wait for start events for animation.
+    if (!data.autoplay || (data.startEvents && data.startEvents.length)) {
+      return;
+    }
+
+    // Delay animation.
+    if (data.delay) {
+      setTimeout(this.beginAnimation, data.delay);
+      return;
+    }
+
+    // Play animation.
+    this.beginAnimation();
+  },
+
+  /**
+   * This is before animation start (including from startEvents).
+   * Set to initial state (config.from, time = 0, seekTime = 0).
+   */
+  beginAnimation: function() {
+    this.updateConfig();
+    this.time = 0;
+    this.animationIsPlaying = true;
+    this.stopRelatedAnimations();
+    this.el.emit("animationbegin", this.eventDetail);
+  },
+
+  pauseAnimation: function() {
+    this.animationIsPlaying = false;
+  },
+
+  resumeAnimation: function() {
+    this.animationIsPlaying = true;
+  },
+
+  /**
+   * startEvents callback.
+   */
+  onStartEvent: function() {
+    if (!this.data.enabled) {
+      return;
+    }
+
+    this.updateConfig();
+    if (this.animation) {
+      this.animation.pause();
+    }
+    this.animation = anime(this.config);
+
+    // Include the delay before each start event.
+    if (this.data.delay) {
+      setTimeout(this.beginAnimation, this.data.delay);
+      return;
+    }
+    this.beginAnimation();
+  },
+
+  /**
+   * rawProperty: true and type: color;
+   */
+  updateConfigForRawColor: function() {
+    const config = this.config;
+    const data = this.data;
+    const el = this.el;
+    let from;
+    let key;
+    let to;
+
+    if (this.waitComponentInitRawProperty(this.updateConfigForRawColor)) {
+      return;
+    }
+
+    from = data.from === "" ? getRawProperty(el, data.property) : data.from;
+    to = data.to;
+
+    // Use r/g/b vector for color type.
+    this.setColorConfig(from, to);
+    from = this.fromColor;
+    to = this.toColor;
+
+    this.targetsArray.length = 0;
+    this.targetsArray.push(from);
+    config.targets = this.targetsArray;
+    for (key in to) {
+      config[key] = to[key];
+    }
+
+    config.update = (function() {
+      const lastValue = {};
+      return function(anim) {
+        const value = anim.animatables[0].target;
+        // For animation timeline.
+        if (value.r === lastValue.r && value.g === lastValue.g && value.b === lastValue.b) {
+          return;
+        }
+
+        setRawProperty(el, data.property, value, data.type);
+      };
+    })();
+  },
+
+  /**
+   * Stuff property into generic `property` key.
+   */
+  updateConfigForDefault: function() {
+    const config = this.config;
+    const data = this.data;
+    const el = this.el;
+    let from;
+    let to;
+
+    if (this.waitComponentInitRawProperty(this.updateConfigForDefault)) {
+      return;
+    }
+
+    if (data.from === "") {
+      // Infer from.
+      from = isRawProperty(data) ? getRawProperty(el, data.property) : getComponentProperty(el, data.property);
+    } else {
+      // Explicit from.
+      from = data.from;
+    }
+
+    to = data.to;
+
+    const isNumber = !isNaN(from || to);
+    if (isNumber) {
+      from = parseFloat(from);
+      to = parseFloat(to);
+    } else {
+      from = from ? from.toString() : from;
+      to = to ? to.toString() : to;
+    }
+
+    // Convert booleans to integer to allow boolean flipping.
+    const isBoolean = data.to === "true" || data.to === "false" || data.to === true || data.to === false;
+    if (isBoolean) {
+      from = data.from === "true" || data.from === true ? 1 : 0;
+      to = data.to === "true" || data.to === true ? 1 : 0;
+    }
+
+    this.targets.aframeProperty = from;
+    config.targets = this.targets;
+    config.aframeProperty = to;
+    config.update = (function() {
+      let lastValue;
+
+      return function(anim) {
+        let value;
+        value = anim.animatables[0].target.aframeProperty;
+
+        // Need to do a last value check for animation timeline since all the tweening
+        // begins simultaenously even if the value has not changed. Also better for perf
+        // anyways.
+        if (value === lastValue) {
+          return;
+        }
+        lastValue = value;
+
+        if (isBoolean) {
+          value = value >= 1;
+        }
+
+        if (isRawProperty(data)) {
+          setRawProperty(el, data.property, value, data.type);
+        } else {
+          setComponentProperty(el, data.property, value);
+        }
+      };
+    })();
+  },
+
+  /**
+   * Extend x/y/z/w onto the config.
+   * Update vector by modifying object3D.
+   */
+  updateConfigForVector: function() {
+    const config = this.config;
+    const data = this.data;
+    const el = this.el;
+    let key;
+
+    // Parse coordinates.
+    const from =
+      data.from !== ""
+        ? utils.coordinates.parse(data.from) // If data.from defined, use that.
+        : getComponentProperty(el, data.property); // If data.from not defined, get on the fly.
+    const to = utils.coordinates.parse(data.to);
+
+    if (data.property === PROP_ROTATION) {
+      toRadians(from);
+      toRadians(to);
+    }
+
+    // Set to and from.
+    this.targetsArray.length = 0;
+    this.targetsArray.push(from);
+    config.targets = this.targetsArray;
+    for (key in to) {
+      config[key] = to[key];
+    }
+
+    // If animating object3D transformation, run more optimized updater.
+    if (data.property === PROP_POSITION || data.property === PROP_ROTATION || data.property === PROP_SCALE) {
+      config.update = (function() {
+        const lastValue = {};
+        return function(anim) {
+          const value = anim.animatables[0].target;
+
+          if (data.property === PROP_SCALE) {
+            value.x = Math.max(0.0001, value.x);
+            value.y = Math.max(0.0001, value.y);
+            value.z = Math.max(0.0001, value.z);
+          }
+
+          // For animation timeline.
+          if (value.x === lastValue.x && value.y === lastValue.y && value.z === lastValue.z) {
+            return;
+          }
+
+          lastValue.x = value.x;
+          lastValue.y = value.y;
+          lastValue.z = value.z;
+
+          el.object3D[data.property].set(value.x, value.y, value.z);
+        };
+      })();
+      return;
+    }
+
+    // Animating some vector.
+    config.update = (function() {
+      const lastValue = {};
+      return function(anim) {
+        const value = anim.animations[0].target;
+
+        // Animate rotation through radians.
+        // For animation timeline.
+        if (value.x === lastValue.x && value.y === lastValue.y && value.z === lastValue.z) {
+          return;
+        }
+        lastValue.x = value.x;
+        lastValue.y = value.y;
+        lastValue.z = value.z;
+        setComponentProperty(el, data.property, value);
+      };
+    })();
+  },
+
+  /**
+   * Update the config before each run.
+   */
+  updateConfig: function() {
+    // Route config type.
+    const propType = getPropertyType(this.el, this.data.property);
+    if (isRawProperty(this.data) && this.data.type === TYPE_COLOR) {
+      this.updateConfigForRawColor();
+    } else if (propType === "vec2" || propType === "vec3" || propType === "vec4") {
+      this.updateConfigForVector();
+    } else {
+      this.updateConfigForDefault();
+    }
+  },
+
+  /**
+   * Wait for component to initialize.
+   */
+  waitComponentInitRawProperty: function(cb) {
+    const data = this.data;
+    const el = this.el;
+    const self = this;
+
+    if (data.from !== "") {
+      return false;
+    }
+
+    if (!data.property.startsWith(STRING_COMPONENTS)) {
+      return false;
+    }
+
+    const componentName = splitDot(data.property)[1];
+    if (el.components[componentName]) {
+      return false;
+    }
+
+    el.addEventListener("componentinitialized", function wait(evt) {
+      if (evt.detail.name !== componentName) {
+        return;
+      }
+      cb();
+      // Since the config was created async, create the animation now since we missed it
+      // earlier.
+      self.animation = anime(self.config);
+      el.removeEventListener("componentinitialized", wait);
+    });
+    return true;
+  },
+
+  /**
+   * Make sure two animations on the same property don't fight each other.
+   * e.g., animation__mouseenter="property: material.opacity"
+   *       animation__mouseleave="property: material.opacity"
+   */
+  stopRelatedAnimations: function() {
+    let component;
+    let componentName;
+    for (componentName in this.el.components) {
+      component = this.el.components[componentName];
+      if (componentName === this.attrName) {
+        continue;
+      }
+      if (component.name !== "animation") {
+        continue;
+      }
+      if (!component.animationIsPlaying) {
+        continue;
+      }
+      if (component.data.property !== this.data.property) {
+        continue;
+      }
+      component.animationIsPlaying = false;
+    }
+  },
+
+  addEventListeners: function() {
+    const data = this.data;
+    const el = this.el;
+    addEventListeners(el, data.startEvents, this.onStartEvent);
+    addEventListeners(el, data.pauseEvents, this.pauseAnimation);
+    addEventListeners(el, data.resumeEvents, this.resumeAnimation);
+  },
+
+  removeEventListeners: function() {
+    const data = this.data;
+    const el = this.el;
+    removeEventListeners(el, data.startEvents, this.onStartEvent);
+    removeEventListeners(el, data.pauseEvents, this.pauseAnimation);
+    removeEventListeners(el, data.resumeEvents, this.resumeAnimation);
+  },
+
+  setColorConfig: function(from, to) {
+    colorHelperFrom.set(from);
+    colorHelperTo.set(to);
+    from = this.fromColor;
+    to = this.toColor;
+    from.r = colorHelperFrom.r;
+    from.g = colorHelperFrom.g;
+    from.b = colorHelperFrom.b;
+    to.r = colorHelperTo.r;
+    to.g = colorHelperTo.g;
+    to.b = colorHelperTo.b;
+  }
+});
diff --git a/src/components/character-controller.js b/src/components/character-controller.js
index 083f47e195e7453136c958ecad62762be882c614..6eb743ec7ba515f19828a9709f44e8f1307f0455 100644
--- a/src/components/character-controller.js
+++ b/src/components/character-controller.js
@@ -3,6 +3,7 @@ const CLAMP_VELOCITY = 0.01;
 const MAX_DELTA = 0.2;
 const EPS = 10e-6;
 const MAX_WARNINGS = 10;
+const PI_2 = Math.PI / 2;
 
 /**
  * Avatar movement controller that listens to move, rotate and teleportation events and moves the avatar accordingly.
@@ -16,7 +17,8 @@ AFRAME.registerComponent("character-controller", {
     easing: { default: 10 },
     pivot: { type: "selector" },
     snapRotationDegrees: { default: THREE.Math.DEG2RAD * 45 },
-    rotationSpeed: { default: -3 }
+    rotationSpeed: { default: -3 },
+    fly: { default: false }
   },
 
   init: function() {
@@ -140,7 +142,7 @@ AFRAME.registerComponent("character-controller", {
       rotationInvMatrix.makeRotationAxis(rotationAxis, -root.rotation.y);
       pivotRotationMatrix.makeRotationAxis(rotationAxis, pivot.rotation.y);
       pivotRotationInvMatrix.makeRotationAxis(rotationAxis, -pivot.rotation.y);
-      this.updateVelocity(deltaSeconds);
+      this.updateVelocity(deltaSeconds, pivot);
       this.accelerationInput.set(0, 0, 0);
 
       const boost = userinput.get(paths.actions.boost) ? 2 : 1;
@@ -178,7 +180,7 @@ AFRAME.registerComponent("character-controller", {
 
       this.pendingSnapRotationMatrix.identity(); // Revert to identity
 
-      if (this.velocity.lengthSq() > EPS) {
+      if (this.velocity.lengthSq() > EPS && !this.data.fly) {
         this.setPositionOnNavMesh(startPos, root.position, root);
       }
     };
@@ -221,13 +223,14 @@ AFRAME.registerComponent("character-controller", {
     pathfinder.clampStep(position, navPosition, this.navNode, this.navZone, this.navGroup, object3D.position);
   },
 
-  updateVelocity: function(dt) {
+  updateVelocity: function(dt, pivot) {
     const data = this.data;
     const velocity = this.velocity;
 
     // If FPS too low, reset velocity.
     if (dt > MAX_DELTA) {
       velocity.x = 0;
+      velocity.y = 0;
       velocity.z = 0;
       return;
     }
@@ -236,17 +239,24 @@ AFRAME.registerComponent("character-controller", {
     if (velocity.x !== 0) {
       velocity.x -= velocity.x * data.easing * dt;
     }
-    if (velocity.z !== 0) {
-      velocity.z -= velocity.z * data.easing * dt;
-    }
     if (velocity.y !== 0) {
       velocity.y -= velocity.y * data.easing * dt;
     }
+    if (velocity.z !== 0) {
+      velocity.z -= velocity.z * data.easing * dt;
+    }
 
     const dvx = data.groundAcc * dt * this.accelerationInput.x;
     const dvz = data.groundAcc * dt * -this.accelerationInput.z;
     velocity.x += dvx;
-    velocity.z += dvz;
+
+    if (this.data.fly) {
+      const pitch = pivot.rotation.x / PI_2;
+      velocity.y += dvz * -pitch;
+      velocity.z += dvz * (1.0 - pitch);
+    } else {
+      velocity.z += dvz;
+    }
 
     const decay = 0.7;
     this.accelerationInput.x = this.accelerationInput.x * decay;
@@ -255,7 +265,7 @@ AFRAME.registerComponent("character-controller", {
     if (Math.abs(velocity.x) < CLAMP_VELOCITY) {
       velocity.x = 0;
     }
-    if (Math.abs(velocity.y) < CLAMP_VELOCITY) {
+    if (this.data.fly && Math.abs(velocity.y) < CLAMP_VELOCITY) {
       velocity.y = 0;
     }
     if (Math.abs(velocity.z) < CLAMP_VELOCITY) {
diff --git a/src/components/cursor-controller.js b/src/components/cursor-controller.js
index 79289c07d7cffdcb3ab2af9cdceb0585bef5aeeb..cc90475b8ed00b7e1bd208e1e50fa5567f4c1372 100644
--- a/src/components/cursor-controller.js
+++ b/src/components/cursor-controller.js
@@ -98,7 +98,11 @@ AFRAME.registerComponent("cursor-controller", {
       const rightHandPose = userinput.get(paths.actions.rightHand.pose);
 
       this.data.cursor.object3D.visible = this.enabled && !!cursorPose;
-      this.el.setAttribute("line", "visible", this.enabled && !!rightHandPose);
+      const lineVisible = !!(this.enabled && rightHandPose);
+
+      if (this.el.getAttribute("line").visible !== lineVisible) {
+        this.el.setAttribute("line", "visible", lineVisible);
+      }
 
       if (!this.enabled || !cursorPose) {
         return;
@@ -129,11 +133,12 @@ AFRAME.registerComponent("cursor-controller", {
       cameraPos.y = cursor.object3D.position.y;
       cursor.object3D.lookAt(cameraPos);
 
-      this.data.cursor.setAttribute(
-        "material",
-        "color",
-        intersection || isGrabbing ? cursorColorHovered : cursorColorUnhovered
-      );
+      const cursorColor = intersection || isGrabbing ? cursorColorHovered : cursorColorUnhovered;
+
+      if (this.data.cursor.getAttribute("material").color !== cursorColor) {
+        this.data.cursor.setAttribute("material", "color", cursorColor);
+      }
+
       if (this.el.components.line.data.visible) {
         this.el.setAttribute("line", {
           start: cursorPose.position.clone(),
diff --git a/src/components/pin-networked-object-button.js b/src/components/pin-networked-object-button.js
index 8f70dee186adc97ab16d49d9f2ca1616cd4f9f07..3b7ae88cc77d697499087033721eee24676fc8e0 100644
--- a/src/components/pin-networked-object-button.js
+++ b/src/components/pin-networked-object-button.js
@@ -61,6 +61,8 @@ AFRAME.registerComponent("pin-networked-object-button", {
     const isPinned = this.targetEl.getAttribute("pinnable") && this.targetEl.getAttribute("pinnable").pinned;
 
     this.labelEl.setAttribute("text", "value", isPinned ? "un-pin" : "pin");
+    this.el.setAttribute("text-button", "backgroundColor", isPinned ? "#fff" : "#ff0520");
+    this.el.setAttribute("text-button", "backgroundHoverColor", isPinned ? "#aaa" : "#cc0515");
 
     this.el.parentNode.querySelectorAll(this.data.hideWhenPinnedSelector).forEach(hideEl => {
       hideEl.setAttribute("visible", !isPinned);
diff --git a/src/components/pinnable.js b/src/components/pinnable.js
index e719c033cde63a9f5d8c5ad025dafeeee2e845ae..a4870c831832701af00f6bfe7c23f739e5e78349 100644
--- a/src/components/pinnable.js
+++ b/src/components/pinnable.js
@@ -42,6 +42,27 @@ AFRAME.registerComponent("pinnable", {
   _fireEvents() {
     if (this.data.pinned) {
       this.el.emit("pinned", { el: this.el });
+
+      this.el.removeAttribute("animation__pin-start");
+      this.el.removeAttribute("animation__pin-end");
+      const currentScale = this.el.object3D.scale;
+
+      this.el.setAttribute("animation__pin-start", {
+        property: "scale",
+        dur: 200,
+        from: { x: currentScale.x, y: currentScale.y, z: currentScale.z },
+        to: { x: currentScale.x * 1.1, y: currentScale.y * 1.1, z: currentScale.z * 1.1 },
+        easing: "easeOutElastic"
+      });
+
+      this.el.setAttribute("animation__pin-end", {
+        property: "scale",
+        delay: 200,
+        dur: 200,
+        from: { x: currentScale.x * 1.1, y: currentScale.y * 1.1, z: currentScale.z * 1.1 },
+        to: { x: currentScale.x, y: currentScale.y, z: currentScale.z },
+        easing: "easeOutElastic"
+      });
     } else {
       this.el.emit("unpinned", { el: this.el });
     }
diff --git a/src/components/position-at-box-shape-border.js b/src/components/position-at-box-shape-border.js
index 962433b6637c5fed3cf0373a31d1c893f62f87be..c013e6e2a80a05f0e88df795a88a00ebf225f074 100644
--- a/src/components/position-at-box-shape-border.js
+++ b/src/components/position-at-box-shape-border.js
@@ -69,14 +69,14 @@ AFRAME.registerComponent("position-at-box-shape-border", {
         }
       }
 
-      if (this.targetEl.getAttribute("visible") === false) return;
-
       if (!this.el.getObject3D("mesh")) {
         return;
       }
 
       if (!this.halfExtents || this.mesh !== this.el.getObject3D("mesh") || this.shape !== this.el.components.shape) {
         this.mesh = this.el.getObject3D("mesh");
+        this.shape = this.el.components.shape;
+
         if (this.el.components.shape) {
           this.shape = this.el.components.shape;
           this.halfExtents.copy(this.shape.data.halfExtents);
@@ -121,8 +121,25 @@ AFRAME.registerComponent("position-at-box-shape-border", {
       const distance = Math.sqrt(minSquareDistance);
       const scale = this.halfExtents[inverseHalfExtents[targetHalfExtentStr]] * distance;
       const targetScale = Math.min(2.0, Math.max(0.5, scale * tempParentWorldScale.x));
+      const finalScale = targetScale / tempParentWorldScale.x;
+
+      const isVisible = this.targetEl.getAttribute("visible");
+
+      if (isVisible && !this.wasVisible) {
+        this.targetEl.removeAttribute("animation__show");
+
+        this.targetEl.setAttribute("animation__show", {
+          property: "scale",
+          dur: 300,
+          from: { x: finalScale * 0.8, y: finalScale * 0.8, z: finalScale * 0.8 },
+          to: { x: finalScale, y: finalScale, z: finalScale },
+          easing: "easeOutElastic"
+        });
+      } else {
+        this.target.scale.setScalar(finalScale);
+      }
 
-      this.target.scale.setScalar(targetScale / tempParentWorldScale.x);
+      this.wasVisible = isVisible;
     };
   })()
 });
diff --git a/src/components/remove-networked-object-button.js b/src/components/remove-networked-object-button.js
index e3f4aa2c020df42fb9603475441f09c53c1e47e6..d3663761b0c99d4df95f58c91b918849d933e958 100644
--- a/src/components/remove-networked-object-button.js
+++ b/src/components/remove-networked-object-button.js
@@ -3,7 +3,19 @@ AFRAME.registerComponent("remove-networked-object-button", {
     this.onClick = () => {
       if (!NAF.utils.isMine(this.targetEl) && !NAF.utils.takeOwnership(this.targetEl)) return;
 
-      this.targetEl.parentNode.removeChild(this.targetEl);
+      this.targetEl.setAttribute("animation__remove", {
+        property: "scale",
+        dur: 200,
+        to: { x: 0.01, y: 0.01, z: 0.01 },
+        easing: "easeInQuad"
+      });
+
+      this.el.parentNode.removeAttribute("visible-while-frozen");
+      this.el.parentNode.setAttribute("visible", false);
+
+      this.targetEl.addEventListener("animationcomplete", () => {
+        this.targetEl.parentNode.removeChild(this.targetEl);
+      });
     };
 
     NAF.utils.getNetworkedEntity(this.el).then(networkedEl => {
diff --git a/src/components/scene-sound.js b/src/components/scene-sound.js
index 249da3ebf130cca91a0ca6a544a3c33e9a3fc4b7..3454fa9ba6b9f50bcfea53d4e983964d47f14a27 100644
--- a/src/components/scene-sound.js
+++ b/src/components/scene-sound.js
@@ -5,11 +5,17 @@ AFRAME.registerComponent("scene-sound", {
   multiple: true,
   schema: {
     sound: { type: "string" },
-    on: { type: "string" }
+    on: { type: "string" },
+    off: { type: "string" }
   },
 
   init() {
     const sound = this.el.components[`${this.attrName.replace("scene-", "")}`];
     this.el.sceneEl.addEventListener(this.data.on, sound.playSound);
+    sound.stopSound = sound.stopSound.bind(sound); // wat
+
+    if (this.data.off) {
+      this.el.sceneEl.addEventListener(this.data.off, sound.stopSound);
+    }
   }
 });
diff --git a/src/gltf-component-mappings.js b/src/gltf-component-mappings.js
index 15c2f584750e1bbf5a4ea81c6c37699369925876..55fe0ab34400e4025ae531b96b7aab5b7abf1c7b 100644
--- a/src/gltf-component-mappings.js
+++ b/src/gltf-component-mappings.js
@@ -34,11 +34,10 @@ AFRAME.GLTFModelPlus.registerComponent(
   "shape",
   (() => {
     const euler = new THREE.Euler();
-    const orientation = new THREE.Quaternion();
     return (el, componentName, componentData) => {
       const { scale, rotation } = componentData;
       euler.set(rotation.x, rotation.y, rotation.z);
-      orientation.setFromEuler(euler);
+      const orientation = new THREE.Quaternion().setFromEuler(euler);
       el.setAttribute(componentName, {
         shape: "box",
         offset: componentData.position,
@@ -80,6 +79,7 @@ AFRAME.GLTFModelPlus.registerComponent("media", "media", (el, componentName, com
     el.setAttribute("networked", {
       template: "#interactable-media",
       owner: "scene",
+      persistent: true,
       networkId: componentData.id
     });
   }
diff --git a/src/hub.html b/src/hub.html
index a16a834139a53a5991a055f443a1d5bcb41043f7..6b469e1351330ac006b602b9f8481ecb58c402e4 100644
--- a/src/hub.html
+++ b/src/hub.html
@@ -54,6 +54,7 @@
             <img id="spawn-pen-hover" crossorigin="anonymous" src="./assets/hud/spawn_pen-hover.png">
             <img id="spawn-camera" crossorigin="anonymous" src="./assets/hud/spawn_camera.png">
             <img id="spawn-camera-hover" crossorigin="anonymous" src="./assets/hud/spawn_camera-hover.png">
+            <img id="presence-count" crossorigin="anonymous" src="./assets/hud/presence-count.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>
@@ -68,29 +69,34 @@
 
             <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>
-            <a-asset-item id="sound_asset-teleport_start"                 src="./assets/sfx/D_teleportStart.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
-            <a-asset-item id="sound_asset-teleport_end"                   src="./assets/sfx/D_teleportEnd.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
-            <a-asset-item id="sound_asset-snap_rotate"                    src="./assets/sfx/quickTurn.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
+            <a-asset-item id="sound_asset-quack"                          src="./assets/sfx/quack.mp3"          response-type="arraybuffer" preload="auto"></a-asset-item>
+            <a-asset-item id="sound_asset-teleport_start"                 src="./assets/sfx/teleportArc.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
+            <a-asset-item id="sound_asset-teleport_end"                   src="./assets/sfx/quickTurn.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
+            <a-asset-item id="sound_asset-snap_rotate"                    src="./assets/sfx/tap_mellow.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
             <a-asset-item id="sound_asset-media_loaded"                   src="./assets/sfx/A_bendUp.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
-            <a-asset-item id="sound_asset-hud_hover_start"                src="./assets/sfx/Eb_blip.wav"  response-type="arraybuffer" preload="auto"></a-asset-item>
-            <a-asset-item id="sound_asset-hover"                          src="./assets/sfx/tap_mellow.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
-            <a-asset-item id="DISABLED_sound_asset-hover_off"             src="./assets/sfx/tap_mellow.wav" response-type="arraybuffer" preload="auto"></a-asset-item>
-            <a-asset-item id="sound_asset-cursor_distance_change_blocked" src="./assets/sfx/tap_mellow.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
-            <a-asset-item id="sound_asset-cursor_distance_changed"        src="./assets/sfx/tap_mellow.wav" response-type="arraybuffer" preload="auto"></a-asset-item>
-            <a-asset-item id="sound_asset-hud_click"                      src="./assets/sfx/tap_mellow.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
-            <a-asset-item id="sound_asset-toggle_mute"                    src="./assets/sfx/Fs_Mute.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
-            <a-asset-item id="sound_asset-toggle_freeze"                  src="./assets/sfx/tap_mellow.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
-            <a-asset-item id="sound_asset-freeze"                         src="./assets/sfx/tap_mellow.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
-            <a-asset-item id="sound_asset-thaw"                           src="./assets/sfx/tap_mellow.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
-            <a-asset-item id="sound_asset-toggle_space_bubble"            src="./assets/sfx/tap_mellow.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
+            <a-asset-item id="sound_asset-media_loading"                  src="./assets/sfx/suspense.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
+            <a-asset-item id="sound_asset-hud_hover_start"                src="./assets/sfx/tick.wav"  response-type="arraybuffer" preload="auto"></a-asset-item>
+            <a-asset-item id="sound_asset-grab"                           src="./assets/sfx/tick.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
+            <a-asset-item id="sound_asset-grab_off"                       src="./assets/sfx/tick.wav" response-type="arraybuffer" preload="auto"></a-asset-item>
+            <a-asset-item id="sound_asset-pinned"                         src="./assets/sfx/tack.wav" response-type="arraybuffer" preload="auto"></a-asset-item>
+            <a-asset-item id="sound_asset-cursor_distance_change_blocked" src="./assets/sfx/tick.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
+            <a-asset-item id="sound_asset-cursor_distance_changed"        src="./assets/sfx/tick.wav" response-type="arraybuffer" preload="auto"></a-asset-item>
+            <a-asset-item id="sound_asset-hud_click"                      src="./assets/sfx/tick.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
+            <a-asset-item id="sound_asset-toggle_mute"                    src="./assets/sfx/tick.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
+            <a-asset-item id="sound_asset-toggle_freeze"                  src="./assets/sfx/Eb_blip.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
+            <a-asset-item id="sound_asset-freeze"                         src="./assets/sfx/Eb_blip.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
+            <a-asset-item id="sound_asset-thaw"                           src="./assets/sfx/tick.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
+            <a-asset-item id="sound_asset-toggle_space_bubble"            src="./assets/sfx/tick.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
             <a-asset-item id="sound_asset-spawn_pen"                      src="./assets/sfx/PenSpawn.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
-            <a-asset-item id="sound_asset-increase_pen_radius"            src="./assets/sfx/tap_mellow.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
-            <a-asset-item id="sound_asset-decrease_pen_radius"            src="./assets/sfx/tap_mellow.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
-            <a-asset-item id="sound_asset-next_pen_color"                 src="./assets/sfx/tap_mellow.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
-            <a-asset-item id="sound_asset-prev_pen_color"                 src="./assets/sfx/tap_mellow.wav" response-type="arraybuffer" preload="auto"></a-asset-item>
+            <a-asset-item id="sound_asset-increase_pen_radius"            src="./assets/sfx/tick.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
+            <a-asset-item id="sound_asset-decrease_pen_radius"            src="./assets/sfx/tick.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
+            <a-asset-item id="sound_asset-next_pen_color"                 src="./assets/sfx/tick.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
+            <a-asset-item id="sound_asset-prev_pen_color"                 src="./assets/sfx/tick.wav" response-type="arraybuffer" preload="auto"></a-asset-item>
             <a-asset-item id="sound_asset-start_draw"                     src="./assets/sfx/PenDraw1.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
-            <a-asset-item id="sound_asset-stop_draw"                      src="./assets/sfx/tap_mellow.wav" response-type="arraybuffer" preload="auto"></a-asset-item>
+            <a-asset-item id="sound_asset-stop_draw"                      src="./assets/sfx/tick.wav" response-type="arraybuffer" preload="auto"></a-asset-item>
             <a-asset-item id="sound_asset-camera_tool_took_snapshot"      src="./assets/sfx/PicSnapHey.wav" response-type="arraybuffer" preload="auto"></a-asset-item>
+            <a-asset-item id="sound_asset-welcome"                        src="./assets/sfx/welcome.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
+            <a-asset-item id="sound_asset-chat"                           src="./assets/sfx/pop.wav"     response-type="arraybuffer" preload="auto"></a-asset-item>
 
             <!-- Templates -->
             <template id="video-template">
@@ -125,8 +131,8 @@
                         <template data-name="Chest">
                             <a-entity personal-space-invader="radius: 0.2; useMaterial: true;" bone-visibility>
                                 <a-entity billboard>
-                                    <a-entity mixin="rounded-text-button" block-button visible-while-frozen="withinDistance: 3;" ui-class-while-frozen position="0 0 .35"> </a-entity>
-                                    <a-entity visible-while-frozen="withinDistance: 3;" text="value:Block; width:2.5; align:center;" position="0 0 0.36"></a-entity>
+                                    <a-entity mixin="rounded-text-button" block-button visible-while-frozen="withinDistance: 10;" ui-class-while-frozen position="0 0 .35"> </a-entity>
+                                    <a-entity visible-while-frozen="withinDistance: 10;" text="value:Block; width:2.5; align:center;" position="0 0 0.36"></a-entity>
                                 </a-entity>
                             </a-entity>
                         </template>
@@ -164,6 +170,8 @@
                     class="interactable"
                     super-networked-interactable="counter: #media-counter;"
                     body="type: dynamic; shape: none; mass: 1;"
+                    scale="0.5 0.5 0.5"
+                    animation__spawn="property: scale; delay: 50; dur: 200; from: 0.5 0.5 0.5; to: 1 1 1; easing: easeInQuad"
                     grabbable
                     stretchable="useWorldPosition: true; usePhysics: never"
                     hoverable
@@ -174,13 +182,15 @@
                     destroy-at-extreme-distances
                     set-yxz-order
                     pinnable
-                    sound__hover="src: #sound_asset-hover; on: hovered; poolSize: 1;"
-                    sound__hoveroff ="src: #sound_asset-hover_off; on: unhovered; poolSize: 1;"
-                    emit-state-change__hovered="state: hovered; transform: rising; event: hovered;"
-                    emit-state-change__unhovered="state: hovered; transform: falling; event: unhovered;"
+                    sound__grab="src: #sound_asset-grab; on: grabbed; poolSize: 1;"
+                    sound__graboff ="src: #sound_asset-grab_off; on: ungrabbed; poolSize: 1;"
+                    sound__pinned ="src: #sound_asset-pinned; on: pinned; poolSize: 1;"
+                    emit-state-change__grabbed="state: grabbed; transform: rising; event: grabbed;"
+                    emit-state-change__ungrabbed="state: grabbed; transform: falling; event: ungrabbed;"
+                    emit-state-change__pinned="state: pinned; transform: rising; event: pinned;"
                 >
                     <a-entity class="interactable-ui" stop-event-propagation__grab-start="event: grab-start" stop-event-propagation__grab-end="event: grab-end">
-                        <a-entity class="freeze-menu" visible-while-frozen="withinDistance: 3;">
+                        <a-entity class="freeze-menu" visible-while-frozen="withinDistance: 10;">
                             <a-entity mixin="rounded-text-action-button" pin-networked-object-button="labelSelector:.pin-button-label; hideWhenPinnedSelector:.hide-when-pinned; uiSelector:.interactable-ui" position="0 0.125 0.01"> </a-entity>
                             <a-entity class="pin-button-label" text=" value:pin; width:1.75; align:center;" text-raycast-hack position="0 0.125 0.02"></a-entity>
                             <a-entity mixin="rounded-text-button" class="hide-when-pinned" remove-networked-object-button position="0 -0.125 0.01"> </a-entity>
@@ -205,10 +215,10 @@
                     sound__stop_draw="src: #sound_asset-stop_draw; on: stop_draw; poolSize: 2;"
                     sound__increase_pen_radius="src: #sound_asset-increase_pen_radius; on: increase_pen_radius; poolSize: 2;"
                     sound__decrease_pen_radius="src: #sound_asset-decrease_pen_radius; on: decrease_pen_radius; poolSize: 2;"
-                    sound__hover="src: #sound_asset-hover; on: hovered; poolSize: 1;"
-                    sound__hoveroff ="src: #sound_asset-hover_off; on: unhovered; poolSize: 1;"
-                    emit-state-change__hovered="state: hovered; transform: rising; event: hovered;"
-                    emit-state-change__unhovered="state: hovered; transform: falling; event: unhovered;"
+                    sound__grab="src: #sound_asset-grab; on: grabbed; poolSize: 1;"
+                    sound__graboff ="src: #sound_asset-grab_off; on: ungrabbed; poolSize: 1;"
+                    emit-state-change__grabbed="state: grabbed; transform: rising; event: grabbed;"
+                    emit-state-change__ungrabbed="state: grabbed; transform: falling; event: ungrabbed;"
                 >
                     <a-sphere
                         id="pen"
@@ -220,7 +230,7 @@
                         segments-width="16"
                         segments-height="12"
                     ></a-sphere>
-                    <a-entity class="delete-button" visible-while-frozen="withinDistance: 3;">
+                    <a-entity class="delete-button" visible-while-frozen="withinDistance: 10;">
                         <a-entity mixin="rounded-text-button" remove-networked-object-button position="0 0 0"> </a-entity>
                         <a-entity text=" value:remove; width:2.5; align:center;" text-raycast-hack position="0 0 0.01"></a-entity>
                     </a-entity>
@@ -234,6 +244,8 @@
                     hoverable
                     stretchable
                     camera-tool
+                    animation__spawn="property: scale; delay: 50; dur: 200; from: 0.5 0.5 0.5; to: 1 1 1; easing: easeOutQuad"
+                    scale="0.5 0.5 0.5"
                     body="type: dynamic; shape: none; mass: 1;"
                     shape="shape: box; halfExtents: 0.22 0.145 0.1; offset: 0 0.02 0"
                     sticky-object="autoLockOnRelease: true; autoLockOnLoad: true; autoLockSpeedLimit: 0;"
@@ -242,7 +254,7 @@
                     set-yxz-order
                     auto-scale-cannon-physics-body
                 >
-                    <a-entity class="delete-button" visible-while-frozen="withinDistance: 3;">
+                    <a-entity class="delete-button" visible-while-frozen="withinDistance: 10;">
                         <a-entity mixin="rounded-text-button" remove-networked-object-button position="0 0 0"> </a-entity>
                         <a-entity text=" value:remove; width:2.5; align:center;" text-raycast-hack position="0 0 0.01"></a-entity>
                     </a-entity>
@@ -292,8 +304,8 @@
                     haptic:#player-right-controller;
                     textHoverColor: #fff;
                     textColor: #fff;
-                    backgroundHoverColor: #ff0434;
-                    backgroundColor: #ff3464;"
+                    backgroundHoverColor: #cc0515;
+                    backgroundColor: #ff0520;"
                 slice9="
                     width: 0.45;
                     height: 0.2;
@@ -382,9 +394,12 @@
                 vr-mode-toggle-playing__hud-controller
             >
                 <a-entity in-world-hud="haptic:#player-right-controller;raycaster:#player-right-controller;" rotation="30 0 0">
-                <a-rounded height="0.08" width="0.5" color="#000000" position="-0.20 0.125 0" radius="0.040" opacity="0.35" class="hud bg"></a-rounded>
-                <a-entity id="hud-hub-entry-link" text=" value:; width:1.1; align:center;" position="0.05 0.165 0"></a-entity>
-                <a-rounded height="0.13" width="0.59" color="#000000" position="-0.24 -0.065 0" radius="0.065" opacity="0.35" class="hud bg"></a-rounded>
+                    <a-rounded height="0.08" width="0.2" color="#000000" position="-0.30 0.125 0" radius="0.040" opacity="0.35" class="hud bg"></a-rounded>
+                    <a-image scale="0.07 0.06 0.06" position="-0.23 0.165 0.001" src="#presence-count" material="alphaTest:0.1;"></a-image>
+                    <a-entity id="hud-presence-count" text="value:; width:1.1; align:center;" position="-0.155 0.165 0"></a-entity>
+                    <a-rounded height="0.08" width="0.5" color="#000000" position="-0.08 0.125 0" radius="0.040" opacity="0.35" class="hud bg"></a-rounded>
+                    <a-entity id="hud-hub-entry-link" text="value:; width:1.1; align:center;" position="0.17 0.165 0"></a-entity>
+                    <a-rounded height="0.13" width="0.59" 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"
@@ -434,10 +449,10 @@
                 rotation
                 pitch-yaw-rotator
                 set-yxz-order
-                sound__teleport_start="positional: false; src: #sound_asset-teleport_start; on: nothing; poolSize: 2;"
-                scene-sound__teleport_start="on: right-teleport_down;"
-                sound__teleport_start2="positional: false; src: #sound_asset-teleport_start; on: nothing; poolSize: 2;"
-                scene-sound__teleport_start2="on: left-teleport_down;"
+                sound__teleport_start="positional: false; src: #sound_asset-teleport_start; on: nothing; poolSize: 2; loop: true;"
+                scene-sound__teleport_start="on: right-teleport_down; off: right-teleport_up;"
+                sound__teleport_start2="positional: false; src: #sound_asset-teleport_start; on: nothing; poolSize: 2; loop: true;"
+                scene-sound__teleport_start2="on: left-teleport_down; off: left-teleport_up;"
                 sound__teleport_end="positional: false; src: #sound_asset-teleport_end; on: nothing; poolSize: 2;"
                 scene-sound__teleport_end="on: right-teleport_up;"
                 sound__teleport_end2="positional: false; src: #sound_asset-teleport_end; on: nothing; poolSize: 2;"
@@ -446,10 +461,10 @@
                 scene-sound__snap_rotate_left="on: snap_rotate_left;"
                 sound__snap_rotate_right="positional: false; src: #sound_asset-snap_rotate; on: nothing; poolSize: 3;"
                 scene-sound__snap_rotate_right="on: snap_rotate_right;"
-                sound__model_loaded="positional: false; src: #sound_asset-media_loaded; on: nothing; poolSize: 2;"
-                scene-sound__model_loaded="on: model-loaded;"
-                sound__image_loaded="positional: false; src: #sound_asset-media_loaded; on: nothing; poolSize: 2;"
-                scene-sound__image_loaded="on: image-loaded;"
+                sound__media_loaded="positional: false; src: #sound_asset-media_loaded; on: nothing; poolSize: 2;"
+                scene-sound__media_loaded="on: media-loaded;"
+                sound__media_loading="positional: false; src: #sound_asset-media_loading; on: nothing; poolSize: 2; loop: true; "
+                scene-sound__media_loading="on: media-loading; off: media-loaded;"
                 sound__hud_action_mute="positional: false; src: #sound_asset-toggle_mute; on: nothing; poolSize: 2;"
                 scene-sound__hud_action_mute="on: action_mute;"
                 sound__hud_action_freeze="positional: false; src: #sound_asset-toggle_freeze; on: nothing; poolSize: 2;"
@@ -463,7 +478,6 @@
                 sound__hud_spawn_pen="positional: false; src: #sound_asset-spawn_pen; on: nothing; poolSize: 2;"
                 scene-sound__hud_spawn_pen="on: spawn_pen;"
                 sound__hud_hover_start="positional: false; src: #sound_asset-hud_hover_start; on: nothing; poolSize: 5;"
-                scene-sound__hud_hover_start="on: play_sound-hud_hover_start;"
                 sound__hud_click="positional: false; src: #sound_asset-hud_click; on: nothing; poolSize: 1;"
                 scene-sound__hud_click="on: hud_click;"
                 sound__cursor_distance_changed="positional: false; src: #sound_asset-cursor_distance_changed; on: nothing; poolSize: 1;"
@@ -472,6 +486,12 @@
                 scene-sound__cursor_distance_change_blocked="on: cursor-distance-change-blocked;"
                 sound__camera_tool_took_snapshot="positional: false; src: #sound_asset-camera_tool_took_snapshot; on: nothing; poolSize: 1;"
                 scene-sound__camera_tool_took_snapshot="on: camera_tool_took_snapshot;"
+                sound__welcome="positional: false; src: #sound_asset-welcome; on: nothing; poolSize: 2;"
+                scene-sound__welcome="on: entered;"
+                sound__log_chat_message="positional: false; src: #sound_asset-chat; on: nothing; poolSize: 2;"
+                scene-sound__log_chat_message="on: presence-log-chat;"
+                sound__quack="positional: false; src: #sound_asset-quack; on: nothing; poolSize: 2;"
+                scene-sound__quack="on: quack;"
             >
                 <a-entity
                     id="gaze-teleport"
diff --git a/src/hub.js b/src/hub.js
index 553f2a3a1fa72b5d5082d6ae391fcf2aefa165b0..a9ac40c324785aab8218a2d871b54e401365c0e5 100644
--- a/src/hub.js
+++ b/src/hub.js
@@ -67,6 +67,7 @@ import "./components/scene-sound";
 import "./components/emit-state-change";
 import "./components/action-to-event";
 import "./components/stop-event-propagation";
+import "./components/animation";
 
 import ReactDOM from "react-dom";
 import React from "react";
@@ -76,6 +77,7 @@ import LinkChannel from "./utils/link-channel";
 import { connectToReticulum } from "./utils/phoenix-utils";
 import { disableiOSZoom } from "./utils/disable-ios-zoom";
 import { proxiedUrlFor } from "./utils/media-utils";
+import MessageDispatch from "./message-dispatch";
 import SceneEntryManager from "./scene-entry-manager";
 import Subscriptions from "./subscriptions";
 
@@ -116,7 +118,6 @@ 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";
@@ -213,7 +214,7 @@ function remountUI(props) {
   mountUI(uiProps);
 }
 
-async function handleHubChannelJoined(entryManager, hubChannel, data) {
+async function handleHubChannelJoined(entryManager, hubChannel, messageDispatch, data) {
   const scene = document.querySelector("a-scene");
 
   if (NAF.connection.isConnected()) {
@@ -251,7 +252,7 @@ async function handleHubChannelJoined(entryManager, hubChannel, data) {
     hubId: hub.hub_id,
     hubName: hub.name,
     hubEntryCode: hub.entry_code,
-    onSendMessage: hubChannel.sendMessage
+    onSendMessage: messageDispatch.dispatch
   });
 
   document
@@ -342,6 +343,7 @@ document.addEventListener("DOMContentLoaded", async () => {
       // If VR headset is activated, refreshing page will fire vrdisplayactivate
       // which puts A-Frame in VR mode, so exit VR mode whenever it is attempted
       // to be entered and we haven't entered the room yet.
+      console.log("Pre-emptively exiting VR mode.");
       scene.exitVR();
     }
   });
@@ -386,6 +388,14 @@ document.addEventListener("DOMContentLoaded", async () => {
 
   if (availableVREntryTypes.isInHMD) {
     remountUI({ availableVREntryTypes, forcedVREntryType: "vr" });
+
+    if (/Oculus/.test(navigator.userAgent)) {
+      // HACK - The polyfill reports Cardboard as the primary VR display on startup out ahead of Oculus Go on Oculus Browser 5.5.0 beta. This display is cached by A-Frame,
+      // so we need to resolve that and get the real VRDisplay before entering as well.
+      const displays = await navigator.getVRDisplays();
+      const vrDisplay = displays.length && displays[0];
+      AFRAME.utils.device.getVRDisplay = () => vrDisplay;
+    }
   } else {
     remountUI({ availableVREntryTypes });
   }
@@ -430,32 +440,13 @@ document.addEventListener("DOMContentLoaded", async () => {
   const joinPayload = { profile: store.state.profile, push_subscription_endpoint: pushSubscriptionEndpoint, context };
   const hubPhxChannel = socket.channel(`hub:${hubId}`, joinPayload);
 
-  hubPhxChannel
-    .join()
-    .receive("ok", async data => {
-      hubChannel.setPhoenixChannel(hubPhxChannel);
-      subscriptions.setHubChannel(hubChannel);
-      subscriptions.setSubscribed(data.subscriptions.web_push);
-      remountUI({ initialIsSubscribed: subscriptions.isSubscribed() });
-      await handleHubChannelJoined(entryManager, hubChannel, data);
-    })
-    .receive("error", res => {
-      if (res.reason === "closed") {
-        entryManager.exitScene();
-        remountUI({ roomUnavailableReason: "closed" });
-      }
-
-      console.error(res);
-    });
-
-  const hubPhxPresence = new Presence(hubPhxChannel);
   const presenceLogEntries = [];
-
   const addToPresenceLog = entry => {
     entry.key = Date.now().toString();
 
     presenceLogEntries.push(entry);
     remountUI({ presenceLogEntries });
+    scene.emit(`presence-log-${entry.type}`);
 
     // Fade out and then remove
     setTimeout(() => {
@@ -469,10 +460,35 @@ document.addEventListener("DOMContentLoaded", async () => {
     }, 20000);
   };
 
+  const messageDispatch = new MessageDispatch(scene, entryManager, hubChannel, addToPresenceLog, remountUI);
+
+  hubPhxChannel
+    .join()
+    .receive("ok", async data => {
+      hubChannel.setPhoenixChannel(hubPhxChannel);
+      subscriptions.setHubChannel(hubChannel);
+      subscriptions.setSubscribed(data.subscriptions.web_push);
+      remountUI({ initialIsSubscribed: subscriptions.isSubscribed() });
+      await handleHubChannelJoined(entryManager, hubChannel, messageDispatch, data);
+    })
+    .receive("error", res => {
+      if (res.reason === "closed") {
+        entryManager.exitScene();
+        remountUI({ roomUnavailableReason: "closed" });
+      }
+
+      console.error(res);
+    });
+
+  const hubPhxPresence = new Presence(hubPhxChannel);
+
   let isInitialSync = true;
+  const vrHudPresenceCount = document.querySelector("#hud-presence-count");
 
   hubPhxPresence.onSync(() => {
     remountUI({ presences: hubPhxPresence.state });
+    const occupantCount = Object.entries(hubPhxPresence.state).length;
+    vrHudPresenceCount.setAttribute("text", "value", occupantCount.toString());
 
     if (!isInitialSync) return;
     // Wire up join/leave event handlers after initial sync.
diff --git a/src/message-dispatch.js b/src/message-dispatch.js
new file mode 100644
index 0000000000000000000000000000000000000000..83babfd2bf7ff3faf2824beb5ac14d9ad96d4a1c
--- /dev/null
+++ b/src/message-dispatch.js
@@ -0,0 +1,80 @@
+import { spawnChatMessage } from "./react-components/chat-message";
+const DUCK_URL = "https://asset-bundles-prod.reticulum.io/interactables/Ducky/DuckyMesh-438ff8e022.gltf";
+
+// Handles user-entered messages
+export default class MessageDispatch {
+  constructor(scene, entryManager, hubChannel, addToPresenceLog, remountUI) {
+    this.scene = scene;
+    this.entryManager = entryManager;
+    this.hubChannel = hubChannel;
+    this.addToPresenceLog = addToPresenceLog;
+    this.remountUI = remountUI;
+  }
+
+  dispatch = message => {
+    if (message.startsWith("/")) {
+      this.dispatchCommand(message.substring(1));
+      document.activeElement.blur(); // Commands should blur
+    } else {
+      this.hubChannel.sendMessage(message);
+    }
+  };
+
+  dispatchCommand = command => {
+    const entered = this.scene.is("entered");
+
+    switch (command) {
+      case "help":
+        // HACK for now, non-trivial to properly send this into React
+        document.querySelector(".help-button").click();
+        return;
+    }
+
+    if (!entered) {
+      this.addToPresenceLog({ type: "log", body: "You must enter the room to use this command." });
+      return;
+    }
+
+    const playerRig = document.querySelector("#player-rig");
+    const scales = [0.0625, 0.125, 0.25, 0.5, 1.0, 1.5, 3, 5, 7.5, 12.5];
+    const curScale = playerRig.object3D.scale;
+
+    switch (command) {
+      case "fly":
+        if (playerRig.getAttribute("character-controller").fly !== true) {
+          playerRig.setAttribute("character-controller", "fly", true);
+          this.addToPresenceLog({ type: "log", body: "Fly mode enabled." });
+        } else {
+          playerRig.setAttribute("character-controller", "fly", false);
+          this.addToPresenceLog({ type: "log", body: "Fly mode disabled." });
+        }
+        break;
+      case "bigger":
+        for (let i = 0; i < scales.length; i++) {
+          if (scales[i] > curScale.x) {
+            playerRig.object3D.scale.set(scales[i], scales[i], scales[i]);
+            break;
+          }
+        }
+
+        break;
+      case "smaller":
+        for (let i = scales.length - 1; i >= 0; i--) {
+          if (curScale.x > scales[i]) {
+            playerRig.object3D.scale.set(scales[i], scales[i], scales[i]);
+            break;
+          }
+        }
+
+        break;
+      case "leave":
+        this.entryManager.exitScene();
+        this.remountUI({ roomUnavailableReason: "left" });
+        break;
+      case "duck":
+        spawnChatMessage(DUCK_URL);
+        this.scene.emit("quack");
+        break;
+    }
+  };
+}
diff --git a/src/react-components/chat-command-help.js b/src/react-components/chat-command-help.js
new file mode 100644
index 0000000000000000000000000000000000000000..6a30a287380ea38764893ae4a4cb285a36bbd489
--- /dev/null
+++ b/src/react-components/chat-command-help.js
@@ -0,0 +1,30 @@
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import styles from "../assets/stylesheets/chat-command-help.scss";
+import { FormattedMessage } from "react-intl";
+
+export default class ChatCommandHelp extends Component {
+  static propTypes = {
+    matchingPrefix: PropTypes.string
+  };
+
+  render() {
+    const commands = ["help", "leave", "fly", "bigger", "smaller", "duck"];
+
+    return (
+      <div className={styles.commandHelp}>
+        {commands.map(
+          c =>
+            (this.props.matchingPrefix === "" || c.startsWith(this.props.matchingPrefix)) && (
+              <div className={styles.entry} key={c}>
+                <div className={styles.command}>/{c}</div>
+                <div>
+                  <FormattedMessage id={`commands.${c}`} />
+                </div>
+              </div>
+            )
+        )}
+      </div>
+    );
+  }
+}
diff --git a/src/react-components/help-dialog.js b/src/react-components/help-dialog.js
index 0e20c0aa98499e87f8e9fa4e52858d610d183f7f..d81b237045238396cd06f891156b9a7c0fa95875 100644
--- a/src/react-components/help-dialog.js
+++ b/src/react-components/help-dialog.js
@@ -19,8 +19,8 @@ export default class HelpDialog extends Component {
           </p>
           <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.
+            Use your controller&apos;s action button to teleport from place to place. If it has a grip, use it to pick
+            up objects.
           </p>
           <p>
             In VR, <b>look up</b> to find your menu.
@@ -29,7 +29,7 @@ export default class HelpDialog extends Component {
             The <b>Mic Toggle</b> mutes your mic.
           </p>
           <p>
-            The <b>Pause/Resume Toggle</b> pauses all other avatars and lets you block others or remove objects.
+            The <b>Pause Toggle</b> pauses all other avatars and lets you block others or pin or remove objects.
           </p>
           <p className="dialog__box__contents__links">
             <WithHoverSound>
diff --git a/src/react-components/home-root.js b/src/react-components/home-root.js
index 521490271cab4cc98bbdfab4e682f23d4fef974d..bbb7e06e9dcbc01242b3a1917017953a702776d5 100644
--- a/src/react-components/home-root.js
+++ b/src/react-components/home-root.js
@@ -175,12 +175,6 @@ class HomeRoot extends Component {
           <div className={mainContentClassNames}>
             <div className={styles.headerContent}>
               <div className={styles.titleAndNav} onClick={() => (document.location = "/")}>
-                <WithHoverSound>
-                  <div className={styles.hubs}>hubs</div>
-                </WithHoverSound>
-                <WithHoverSound>
-                  <div className={styles.preview}>preview</div>
-                </WithHoverSound>
                 <div className={styles.links}>
                   <WithHoverSound>
                     <a href="https://github.com/mozilla/hubs" rel="noreferrer noopener">
diff --git a/src/react-components/presence-list.js b/src/react-components/presence-list.js
index 40d18b9d34b63e0a1f4871cf75b9c6a63923ce55..70e6633b5fc7db4863af0f59bf4fcb945e544ab0 100644
--- a/src/react-components/presence-list.js
+++ b/src/react-components/presence-list.js
@@ -22,8 +22,8 @@ export default class PresenceList extends Component {
     const image = context && context.mobile ? PhoneImage : context && context.hmd ? HMDImage : DesktopImage;
 
     return (
-      <WithHoverSound>
-        <div className={styles.row} key={sessionId}>
+      <WithHoverSound key={sessionId}>
+        <div className={styles.row}>
           <div className={styles.device}>
             <img src={image} />
           </div>
diff --git a/src/react-components/presence-log.js b/src/react-components/presence-log.js
index 4426ac46e1e86d22e82b01de4f59152bb8b2184f..7eabb323aadd38e5eb3d37d5a669582a9f5699fc 100644
--- a/src/react-components/presence-log.js
+++ b/src/react-components/presence-log.js
@@ -56,7 +56,7 @@ export default class PresenceLog extends Component {
             maySpawn={e.maySpawn}
           />
         );
-      case "spawn": {
+      case "spawn":
         return (
           <PhotoMessage
             key={e.key}
@@ -67,7 +67,12 @@ export default class PresenceLog extends Component {
             hubId={this.props.hubId}
           />
         );
-      }
+      case "log":
+        return (
+          <div key={e.key} className={classNames(entryClasses)}>
+            {e.body}
+          </div>
+        );
     }
   };
 
diff --git a/src/react-components/ui-root.js b/src/react-components/ui-root.js
index 77b0dfba4310c7986f9e4775cc38aa35c2449e8b..07a85c470178b2f04c270360b039fed303fc2be1 100644
--- a/src/react-components/ui-root.js
+++ b/src/react-components/ui-root.js
@@ -31,6 +31,7 @@ import CreateObjectDialog from "./create-object-dialog.js";
 import PresenceLog from "./presence-log.js";
 import PresenceList from "./presence-list.js";
 import TwoDHUD from "./2d-hud";
+import ChatCommandHelp from "./chat-command-help";
 import { spawnChatMessage } from "./chat-message";
 import { faUsers } from "@fortawesome/free-solid-svg-icons/faUsers";
 
@@ -679,12 +680,12 @@ class UIRoot extends Component {
         <div>
           <FormattedMessage id={exitSubtitleId} />
           <p />
-          {this.props.roomUnavailableReason && (
+          {this.props.roomUnavailableReason !== "left" && (
             <div>
               You can also{" "}
               <WithHoverSound>
-                <a href="/">create a new room</a>.
-              </WithHoverSound>
+                <a href="/">create a new room</a>
+              </WithHoverSound>.
             </div>
           )}
         </div>
@@ -1101,6 +1102,9 @@ class UIRoot extends Component {
             {entryFinished && (
               <form onSubmit={this.sendMessage}>
                 <div className={styles.messageEntryInRoom} style={{ height: pendingMessageFieldHeight }}>
+                  {this.state.pendingMessage.startsWith("/") && (
+                    <ChatCommandHelp matchingPrefix={this.state.pendingMessage.substring(1)} />
+                  )}
                   <textarea
                     style={{ height: pendingMessageTextareaHeight }}
                     className={classNames([
@@ -1209,7 +1213,7 @@ class UIRoot extends Component {
             )}
 
             <WithHoverSound>
-              <button onClick={() => this.showHelpDialog()} className={styles.helpIcon}>
+              <button onClick={() => this.showHelpDialog()} className={classNames([styles.helpIcon, "help-button"])}>
                 <i>
                   <FontAwesomeIcon icon={faQuestion} />
                 </i>
diff --git a/src/scene-entry-manager.js b/src/scene-entry-manager.js
index 1f8e87d60fae179a802a9cf246cd79f1be09cead..ed34939f2e6ec0a38cd7858d7c2003eb1f48776e 100644
--- a/src/scene-entry-manager.js
+++ b/src/scene-entry-manager.js
@@ -51,6 +51,9 @@ export default class SceneEntryManager {
     }
 
     if (enterInVR) {
+      // HACK - A-Frame calls getVRDisplays at module load, we want to do it here to
+      // force gamepads to become live.
+      navigator.getVRDisplays();
       this.scene.enterVR();
     } else if (AFRAME.utils.device.isMobile()) {
       document.body.addEventListener("touchend", requestFullscreen);
@@ -75,6 +78,8 @@ export default class SceneEntryManager {
       return;
     }
 
+    this.scene.setAttribute("motion-capture-replayer", "enabled", false);
+
     if (mediaStream) {
       NAF.connection.adapter.setLocalMediaStream(mediaStream);
     }
diff --git a/src/spoke.js b/src/spoke.js
index d1f4fb3aa7322667d62faa8edf2b57adc296f48a..1764ba2eea6d2fc0088ed0e97bd14e7cf645e89d 100644
--- a/src/spoke.js
+++ b/src/spoke.js
@@ -44,7 +44,8 @@ class SpokeLanding extends Component {
       platform: getPlatform(),
       downloadClicked: false,
       downloadLinkForCurrentPlatform: {},
-      showPlayer: false
+      showPlayer: false,
+      playerVideoId: "WmQKZJPhV7s"
     };
   }
 
@@ -189,9 +190,20 @@ class SpokeLanding extends Component {
                         <FormattedMessage id="spoke.browse_all_versions" />
                       </a>
                     )}
-                  <button className={styles.playButton} onClick={() => this.setState({ showPlayer: true })}>
-                    <FormattedMessage id="spoke.play_button" />
-                  </button>
+                  <div className={styles.tutorialButtons}>
+                    <button
+                      className={styles.playButton}
+                      onClick={() => this.setState({ showPlayer: true, playerVideoId: "WmQKZJPhV7s" })}
+                    >
+                      <FormattedMessage id="spoke.beginner_tutorial_button" />
+                    </button>
+                    <button
+                      className={styles.playButton}
+                      onClick={() => this.setState({ showPlayer: true, playerVideoId: "1Yg5x4Plz_4" })}
+                    >
+                      <FormattedMessage id="spoke.advanced_tutorial_button" />
+                    </button>
+                  </div>
                 </div>
               </div>
               <div className={styles.heroVideo}>
@@ -210,7 +222,7 @@ class SpokeLanding extends Component {
                 <YouTube
                   className={styles.playerVideo}
                   opts={{ rel: 0 }}
-                  videoId="WmQKZJPhV7s"
+                  videoId={this.state.playerVideoId}
                   onReady={e => e.target.playVideo()}
                 />
                 {platform !== "unsupported" && (
diff --git a/src/systems/tunnel-effect.js b/src/systems/tunnel-effect.js
index 88217e6757d8898c89009bef89b2a7c201b28b14..a254b7800f6fe1030df000cf00e3ef8fc8adf819 100644
--- a/src/systems/tunnel-effect.js
+++ b/src/systems/tunnel-effect.js
@@ -158,6 +158,8 @@ AFRAME.registerSystem("tunneleffect", {
    * use the render func of the effect composer when we need the postprocessing
    */
   _bindRenderFunc: function() {
-    this.scene.renderer.render = this.postProcessingRenderFunc;
+    if (this.postProcessingRenderFunc) {
+      this.scene.renderer.render = this.postProcessingRenderFunc;
+    }
   }
 });
diff --git a/src/systems/userinput/devices/keyboard.js b/src/systems/userinput/devices/keyboard.js
index 572e78596eb677e96e04a70c49f09465e4f0efc8..a2338567d0666a5477f622af969efa14e3e2297a 100644
--- a/src/systems/userinput/devices/keyboard.js
+++ b/src/systems/userinput/devices/keyboard.js
@@ -4,12 +4,13 @@ export class KeyboardDevice {
     this.keys = {};
     this.events = [];
 
-    ["keydown", "keyup", "blur", "mouseout"].map(x => document.addEventListener(x, this.events.push.bind(this.events)));
+    ["keydown", "keyup"].map(x => document.addEventListener(x, this.events.push.bind(this.events)));
+    ["blur"].map(x => window.addEventListener(x, this.events.push.bind(this.events)));
   }
 
   write(frame) {
     this.events.forEach(event => {
-      if (event.type === "blur" || event.type === "mouseout") {
+      if (event.type === "blur") {
         this.keys = {};
         return;
       }
diff --git a/src/systems/userinput/userinput.js b/src/systems/userinput/userinput.js
index 7bd37e7ee4cd65ad9c4e1ce8cddb814f9c836a4e..58136575df7246432debf21049b1c7e78b715603 100644
--- a/src/systems/userinput/userinput.js
+++ b/src/systems/userinput/userinput.js
@@ -70,98 +70,101 @@ AFRAME.registerSystem("userinput", {
     this.pendingSetChanges = [];
     this.activeDevices = new Set([new MouseDevice(), new AppAwareMouseDevice(), new KeyboardDevice(), new HudDevice()]);
 
+    if (AFRAME.utils.device.isMobile()) {
+      this.activeDevices.add(new AppAwareTouchscreenDevice());
+    }
+
     this.registeredMappings = new Set([keyboardDebuggingBindings]);
     this.xformStates = new Map();
 
-    let connectedGamepadBindings;
+    const vrGamepadMappings = new Map();
+    vrGamepadMappings.set(ViveControllerDevice, viveUserBindings);
+    vrGamepadMappings.set(OculusTouchControllerDevice, oculusTouchUserBindings);
+    vrGamepadMappings.set(OculusGoControllerDevice, oculusGoUserBindings);
+    vrGamepadMappings.set(DaydreamControllerDevice, daydreamUserBindings);
 
-    const appAwareTouchscreenDevice = new AppAwareTouchscreenDevice();
+    const nonVRGamepadMappings = new Map();
+    nonVRGamepadMappings.set(XboxControllerDevice, xboxControllerUserBindings);
+    nonVRGamepadMappings.set(GamepadDevice, gamepadBindings);
+
+    const updateBindingsForVRMode = () => {
+      const inVRMode = this.el.sceneEl.is("vr-mode");
+      const isMobile = AFRAME.utils.device.isMobile();
 
-    const disableNonGamepadBindings = () => {
-      if (AFRAME.utils.device.isMobile()) {
-        this.activeDevices.delete(appAwareTouchscreenDevice);
-        this.registeredMappings.delete(touchscreenUserBindings);
+      if (inVRMode) {
+        console.log("Using VR bindings.");
+        this.registeredMappings.delete(isMobile ? touchscreenUserBindings : keyboardMouseUserBindings);
+        // add mappings for all active VR input devices
+        for (const activeDevice of this.activeDevices) {
+          const mapping = vrGamepadMappings.get(activeDevice.constructor);
+          mapping && this.registeredMappings.add(mapping);
+        }
       } else {
-        this.registeredMappings.delete(keyboardMouseUserBindings);
+        console.log("Using Non-VR bindings.");
+        // remove mappings for all active VR input devices
+        for (const activeDevice of this.activeDevices) {
+          this.registeredMappings.delete(vrGamepadMappings.get(activeDevice.constructor));
+        }
+        this.registeredMappings.add(isMobile ? touchscreenUserBindings : keyboardMouseUserBindings);
       }
-    };
 
-    const enableNonGamepadBindings = () => {
-      if (AFRAME.utils.device.isMobile()) {
-        this.activeDevices.add(appAwareTouchscreenDevice);
-        this.registeredMappings.add(touchscreenUserBindings);
-      } else {
-        this.registeredMappings.add(keyboardMouseUserBindings);
+      for (const activeDevice of this.activeDevices) {
+        const mapping = nonVRGamepadMappings.get(activeDevice.constructor);
+        mapping && this.registeredMappings.add(mapping);
       }
     };
 
-    const updateBindingsForVRMode = () => {
-      const inVRMode = this.el.sceneEl.is("vr-mode");
-
-      if (inVRMode) {
-        disableNonGamepadBindings();
-        this.registeredMappings.add(connectedGamepadBindings);
+    const gamepadConnected = e => {
+      let gamepadDevice;
+      for (const activeDevice of this.activeDevices) {
+        if (activeDevice.gamepad && activeDevice.gamepad.index === e.gamepad.index) {
+          console.warn("connected already fired for gamepad", e.gamepad);
+          return; // multiple connect events without a disconnect event
+        }
+      }
+      if (e.gamepad.id === "OpenVR Gamepad") {
+        gamepadDevice = new ViveControllerDevice(e.gamepad);
+      } else if (e.gamepad.id.startsWith("Oculus Touch")) {
+        gamepadDevice = new OculusTouchControllerDevice(e.gamepad);
+      } else if (e.gamepad.id === "Oculus Go Controller") {
+        gamepadDevice = new OculusGoControllerDevice(e.gamepad);
+      } else if (e.gamepad.id === "Daydream Controller") {
+        gamepadDevice = new DaydreamControllerDevice(e.gamepad);
+      } else if (e.gamepad.id.includes("Xbox")) {
+        gamepadDevice = new XboxControllerDevice(e.gamepad);
       } else {
-        enableNonGamepadBindings();
-        this.registeredMappings.delete(connectedGamepadBindings);
+        gamepadDevice = new GamepadDevice(e.gamepad);
       }
+
+      this.activeDevices.add(gamepadDevice);
+
+      updateBindingsForVRMode();
     };
-    this.el.sceneEl.addEventListener("enter-vr", updateBindingsForVRMode);
-    this.el.sceneEl.addEventListener("exit-vr", updateBindingsForVRMode);
-    updateBindingsForVRMode();
 
-    window.addEventListener(
-      "gamepadconnected",
-      e => {
-        let gamepadDevice;
-        const entered = this.el.sceneEl.is("entered");
-        for (let i = 0; i < this.activeDevices.length; i++) {
-          const activeDevice = this.activeDevices[i];
-          if (activeDevice.gamepad && activeDevice.gamepad === e.gamepad) {
-            console.warn("ignoring gamepad", e.gamepad);
-            return; // multiple connect events without a disconnect event
-          }
-        }
-        if (e.gamepad.id === "OpenVR Gamepad") {
-          gamepadDevice = new ViveControllerDevice(e.gamepad);
-          connectedGamepadBindings = viveUserBindings;
-        } else if (e.gamepad.id.startsWith("Oculus Touch")) {
-          gamepadDevice = new OculusTouchControllerDevice(e.gamepad);
-          connectedGamepadBindings = oculusTouchUserBindings;
-        } else if (e.gamepad.id === "Oculus Go Controller") {
-          gamepadDevice = new OculusGoControllerDevice(e.gamepad);
-          connectedGamepadBindings = oculusGoUserBindings;
-        } else if (e.gamepad.id === "Daydream Controller") {
-          gamepadDevice = new DaydreamControllerDevice(e.gamepad);
-          connectedGamepadBindings = daydreamUserBindings;
-        } else if (e.gamepad.id.includes("Xbox")) {
-          gamepadDevice = new XboxControllerDevice(e.gamepad);
-          connectedGamepadBindings = xboxControllerUserBindings;
-        } else {
-          gamepadDevice = new GamepadDevice(e.gamepad);
-          connectedGamepadBindings = gamepadBindings;
+    const gamepadDisconnected = e => {
+      for (const device of this.activeDevices) {
+        if (device.gamepad && device.gamepad.index === e.gamepad.index) {
+          this.registeredMappings.delete(
+            vrGamepadMappings.get(device.constructor) || nonVRGamepadMappings.get(device.constructor)
+          );
+          this.activeDevices.delete(device);
+          return;
         }
+      }
 
-        if (entered) {
-          this.registeredMappings.add(connectedGamepadBindings);
-        }
+      updateBindingsForVRMode();
+    };
 
-        this.activeDevices.add(gamepadDevice);
-      },
-      false
-    );
-    window.addEventListener(
-      "gamepaddisconnected",
-      e => {
-        for (const device of this.activeDevices) {
-          if (device.gamepad === e.gamepad) {
-            this.activeDevices.delete(device);
-            return;
-          }
-        }
-      },
-      false
-    );
+    window.addEventListener("gamepadconnected", gamepadConnected, false);
+    window.addEventListener("gamepaddisconnected", gamepadDisconnected, false);
+    for (const gamepad of navigator.getGamepads()) {
+      gamepad && gamepadConnected({ gamepad });
+    }
+
+    this.el.sceneEl.addEventListener("enter-vr", updateBindingsForVRMode);
+    this.el.sceneEl.addEventListener("exit-vr", updateBindingsForVRMode);
+
+    updateBindingsForVRMode();
   },
 
   tick() {
@@ -197,6 +200,7 @@ AFRAME.registerSystem("userinput", {
       console.log("frame", this.frame);
       console.log("sets", this.activeSets);
       console.log("bindings", this.activeBindings);
+      console.log("mappings", this.registeredMappings);
       console.log("devices", this.activeDevices);
       console.log("xformStates", this.xformStates);
     }
diff --git a/src/utils/media-utils.js b/src/utils/media-utils.js
index 7a05a2c725b9ade88eb21bdd0df6e075459f8920..20bce73736f73f7275b5363cb8dec652767b4b3d 100644
--- a/src/utils/media-utils.js
+++ b/src/utils/media-utils.js
@@ -108,6 +108,31 @@ export const addMedia = (src, template, contentOrigin, resolve = false, resize =
   entity.setAttribute("media-loader", { resize, resolve, src: typeof src === "string" ? src : "" });
   scene.appendChild(entity);
 
+  const fireLoadingTimeout = setTimeout(() => {
+    scene.emit("media-loading", { src: src });
+  }, 100);
+
+  ["model-loaded", "video-loaded", "image-loaded"].forEach(eventName => {
+    entity.addEventListener(eventName, () => {
+      clearTimeout(fireLoadingTimeout);
+
+      if (!entity.classList.contains("pen")) {
+        entity.object3D.scale.setScalar(0.5);
+
+        entity.setAttribute("animation__spawn-start", {
+          property: "scale",
+          delay: 50,
+          dur: 300,
+          from: { x: 0.5, y: 0.5, z: 0.5 },
+          to: { x: 1.0, y: 1.0, z: 1.0 },
+          easing: "easeOutElastic"
+        });
+      }
+
+      scene.emit("media-loaded", { src: src });
+    });
+  });
+
   const orientation = new Promise(function(resolve) {
     if (src instanceof File) {
       getOrientation(src, x => {
@@ -156,6 +181,7 @@ export function injectCustomShaderChunks(obj) {
     // hover/toggle state, so for now just skip these while we figure out a more correct
     // solution.
     if (object.el.classList.contains("ui")) return;
+    if (object.el.getAttribute("text-button")) return;
 
     object.material = object.material.clone();
     object.material.onBeforeCompile = shader => {