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/translations.data.json b/src/assets/translations.data.json
index 67ec0078c9d052d4d504053ee6dcd973eb6b7a45..61851b825eecc3710f30f7ba0524c3ca6584839e 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.",
@@ -111,6 +112,12 @@
     "spoke.download_unsupported": "View Releases",
     "spoke.browse_all_versions": "Browse All Versions",
     "spoke.close": "Close",
-    "spoke.play_button": "Learn Spoke in 5 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/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/hub.html b/src/hub.html
index 9ca50bc75c84d4fa84cf0c871bfef86969dba7f6..8905bed8c22d74cd847f8dbd65663c1fd0030f72 100644
--- a/src/hub.html
+++ b/src/hub.html
@@ -68,9 +68,10 @@
 
             <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-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/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-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>
@@ -94,6 +95,7 @@
             <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">
@@ -480,6 +482,10 @@
                 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 f08f7d3b60c8bf73317435718710998ac3a0d33f..2cd48aee5207f47666c4723547be11047d0a126d 100644
--- a/src/hub.js
+++ b/src/hub.js
@@ -77,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";
 
@@ -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
@@ -431,32 +432,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(() => {
@@ -470,6 +452,28 @@ 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;
 
   hubPhxPresence.onSync(() => {
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..53dfb7b1929f70f3191a6b53c0a0265cbea1bef5
--- /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}>
+                <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/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>