diff --git a/src/components/axis-dpad.js b/src/components/axis-dpad.js
index 853e1569714a87ec0da79dea7fa423cabd721b36..b4ccef1298dd0ac434eba62138f7860b1da1b024 100644
--- a/src/components/axis-dpad.js
+++ b/src/components/axis-dpad.js
@@ -8,18 +8,7 @@
  * @TODO make axes configurable
  */
 
-const angleToDirection = function(angle) {
-  angle = (angle * THREE.Math.RAD2DEG + 180 + 45) % 360;
-  if (angle > 0 && angle < 90) {
-    return "down";
-  } else if (angle >= 90 && angle < 180) {
-    return "left";
-  } else if (angle >= 180 && angle < 270) {
-    return "up";
-  } else {
-    return "right";
-  }
-};
+import angleToDirection from "../utils";
 
 AFRAME.registerComponent("axis-dpad", {
   schema: {
diff --git a/src/components/character-controller.js b/src/components/character-controller.js
index b92077fc9e2334593d458320cdb9a93a7757f90d..b1c1caf386ca3d1a8691c08acfa15ab02ac8395b 100644
--- a/src/components/character-controller.js
+++ b/src/components/character-controller.js
@@ -17,7 +17,7 @@ AFRAME.registerComponent("character-controller", {
     this.boost = 1.0;
     this.pendingSnapRotationMatrix = new THREE.Matrix4();
     this.angularVelocity = 0; // Scalar value because we only allow rotation around Y
-    this.onMove = this.onMove.bind(this);
+    this.setAccelerationInput = this.setAccelerationInput.bind(this);
     this.onBoost = this.onBoost.bind(this);
     this.onSnapRotateLeft = this.onSnapRotateLeft.bind(this);
     this.onSnapRotateRight = this.onSnapRotateRight.bind(this);
@@ -35,7 +35,7 @@ AFRAME.registerComponent("character-controller", {
 
   play: function() {
     const eventSrc = this.el.sceneEl;
-    eventSrc.addEventListener("move", this.onMove);
+    eventSrc.addEventListener("move", this.setAccelerationInput);
     eventSrc.addEventListener("rotateY", this.onRotateY);
     eventSrc.addEventListener("action_snap_rotate_left", this.onSnapRotateLeft);
     eventSrc.addEventListener(
@@ -47,7 +47,7 @@ AFRAME.registerComponent("character-controller", {
 
   pause: function() {
     const eventSrc = this.el.sceneEl;
-    eventSrc.removeEventListener("move", this.onMove);
+    eventSrc.removeEventListener("move", this.setAccelerationInput);
     eventSrc.removeEventListener("rotateY", this.onRotateY);
     eventSrc.removeEventListener(
       "action_snap_rotate_left",
@@ -59,7 +59,7 @@ AFRAME.registerComponent("character-controller", {
     );
   },
 
-  onMove: function(event) {
+  setAccelerationInput: function(event) {
     const axes = event.detail.axis;
     this.accelerationInput.set(axes[0], 0, axes[1]);
   },
diff --git a/src/components/dpad-as-axes.js b/src/components/dpad-as-axes.js
new file mode 100644
index 0000000000000000000000000000000000000000..c0a232a5554d8b066e79bebc35ceff52e0c00c14
--- /dev/null
+++ b/src/components/dpad-as-axes.js
@@ -0,0 +1,74 @@
+AFRAME.registerComponent("dpad-as-axes", {
+  schema: {
+    dpadActionPrefix: { default: "dpad" },
+    analog2dOutputAction: { default: "keyboard_dpad_axes" },
+    emitter: { default: "#left-hand" }
+  },
+
+  init: function() {
+    this.handlers = [];
+    this.directionsAndAxes = [
+      {
+        direction: "north",
+        axes: [0, 1]
+      },
+      {
+        direction: "northeast",
+        axes: [1, 1]
+      },
+      {
+        direction: "east",
+        axes: [1, 0]
+      },
+      {
+        direction: "southeast",
+        axes: [1, -1]
+      },
+      {
+        direction: "south",
+        axes: [0, -1]
+      },
+      {
+        direction: "southwest",
+        axes: [-1, -1]
+      },
+      {
+        direction: "west",
+        axes: [-1, 0]
+      },
+      {
+        direction: "northwest",
+        axes: [-1, 1]
+      }
+    ];
+  },
+
+  play: function() {
+    const inputAction = this.data.dpadActionPrefix;
+    for (var tuple of this.directionsAndAxes) {
+      this.handlers[tuple.direction] = this.emitAnalog2d(tuple.axes).bind(this);
+      this.el.addEventListener(
+        `${inputAction}_${tuple.direction}`,
+        this.handlers[tuple.direction]
+      );
+    }
+  },
+
+  pause: function() {
+    const inputAction = this.data.dpadActionPrefix;
+    for (var tuple of this.directionsAndAxes) {
+      this.el.removeEventListener(
+        `${inputAction}_${tuple.direction}`,
+        this.handlers[tuple.direction]
+      );
+    }
+  },
+
+  emitAnalog2d: function(axes) {
+    const outputAction = this.data.analog2dOutputAction;
+    const emitter = document.querySelector(this.data.emitter);
+    return function(event) {
+      emitter.emit(outputAction, { axis: [axes[0], axes[1]] });
+    };
+  }
+});
diff --git a/src/components/haptic-feedback.js b/src/components/haptic-feedback.js
new file mode 100644
index 0000000000000000000000000000000000000000..0ac0512d2cf7fe9d16724c6441036484d321cf59
--- /dev/null
+++ b/src/components/haptic-feedback.js
@@ -0,0 +1,50 @@
+AFRAME.registerComponent("haptic-feedback", {
+  schema: {
+    hapticEventName: { default: "haptic_pulse" }
+  },
+
+  init: function() {
+    this.pulse = this.pulse.bind(this);
+    this.tryGetActuator = this.tryGetActuator.bind(this);
+    this.tryGetActuator();
+  },
+
+  tryGetActuator() {
+    var trackedControls = this.el.components["tracked-controls"];
+    if (trackedControls && trackedControls.controller) {
+      this.actuator = trackedControls.controller.hapticActuators[0];
+    } else {
+      setTimeout(this.tryGetActuator, 1000);
+    }
+  },
+
+  play: function() {
+    this.el.addEventListener(`${this.data.hand}_haptic_pulse`, this.pulse);
+  },
+  pause: function() {
+    this.el.removeEventListener(`${this.data.hand}_haptic_pulse`, this.pulse);
+  },
+
+  pulse: function(event) {
+    let { strength, duration, intensity } = event.detail;
+    switch (intensity) {
+      case "low": {
+        strength = 0.07;
+        duration = 12;
+      }
+      case "medium": {
+        strength = 0.2;
+        duration = 12;
+      }
+      case "high": {
+        strength = 1;
+        duration = 12;
+      }
+      case "none": {
+        return;
+      }
+    }
+
+    this.actuator.pulse(strength, duration);
+  }
+});
diff --git a/src/components/keyboard-dpad.js b/src/components/keyboard-dpad.js
new file mode 100644
index 0000000000000000000000000000000000000000..7ce1eb8d5e4629bbce486642e2efbd95b9cc58d6
--- /dev/null
+++ b/src/components/keyboard-dpad.js
@@ -0,0 +1,56 @@
+/// Listens to four keyboard events, then emits dpad events.
+AFRAME.registerComponent("keyboard-dpad", {
+  schema: {
+    north: { default: "w" },
+    east: { default: "d" },
+    south: { default: "s" },
+    west: { default: "a" },
+    dpadActionPrefix: { type: "string" }
+  },
+
+  init: function() {
+    this.onKeyPress = this.onKeyPress.bind(this);
+    this.onKeyUp = this.onKeyUp.bind(this);
+    this.keys = {};
+  },
+
+  play: function() {
+    window.addEventListener("keypress", this.onKeyPress);
+    window.addEventListener("keyup", this.onKeyUp);
+  },
+
+  pause: function() {
+    window.remove("keypress", this.onKeyPress);
+    window.remove("keyup", this.onKeyUp);
+  },
+
+  tick: function(t, dt) {
+    const { north, east, south, west, dpadActionPrefix } = this.data;
+    var direction = "";
+    direction += this.keys[north] ? "north" : "";
+    direction += this.keys[south] ? "south" : "";
+    direction += this.keys[east] ? "east" : "";
+    direction += this.keys[west] ? "west" : "";
+    if (direction !== "") {
+      this.el.emit(`${dpadActionPrefix}_${direction}`);
+    }
+  },
+
+  onKeyPress: function(event) {
+    const { north, east, south, west } = this.data;
+    for (var dir of [north, south, east, west]) {
+      if (event.key === dir) {
+        this.keys[dir] = true;
+      }
+    }
+  },
+
+  onKeyUp: function(event) {
+    const { north, east, south, west } = this.data;
+    for (var dir of [north, south, east, west]) {
+      if (event.key === dir) {
+        this.keys[dir] = false;
+      }
+    }
+  }
+});
diff --git a/src/components/oculus-touch-controls-extended.js b/src/components/oculus-touch-controls-extended.js
new file mode 100644
index 0000000000000000000000000000000000000000..519e33c34fa19391f108849daf85073d80e38cb2
--- /dev/null
+++ b/src/components/oculus-touch-controls-extended.js
@@ -0,0 +1,62 @@
+import { angleTo4Direction, angleTo8Direction } from "../utils";
+
+AFRAME.registerComponent("oculus-touch-controls-extended", {
+  schema: {
+    hand: { default: "left" },
+    dpad_enabled: { default: false },
+    dpad_deadzone: { default: 0.85 },
+    dpad_livezone: { default: 0.35 },
+    dpad_directions: { default: 4 }, // one of [4, 8]
+    dpad_turbo: { default: false },
+    dpad_haptic_intensity: { default: "none" } // one of ["none", "low", "medium", "high"]
+  },
+
+  init: function() {
+    this.filterAxisMove = this.filterAxisMove.bind(this);
+    this.dpadCanFire = true;
+  },
+
+  play: function(old) {
+    this.el.addEventListener("axismove", this.filterAxisMove);
+  },
+
+  pause: function(old) {
+    this.el.removeEventListener("axismove", this.filterAxisMove);
+  },
+
+  filterAxisMove: function(event) {
+    const x = event.detail.axis[0];
+    const y = event.detail.axis[1];
+    const deadzone = this.data.dpad_deadzone;
+    const turbo = this.data.dpad_turbo;
+    const livezone = this.data.dpad_livezone;
+    const directions = this.data.dpad_directions;
+    const haptic_intensity = this.data.dpad_haptic_intensity;
+    const hand = this.data.hand;
+
+    event.target.emit(`${hand}_axismove`, {
+      axis: [event.detail.axis[0], -event.detail.axis[1]]
+    });
+
+    if (!this.data.dpad_enabled) {
+      return;
+    }
+    if (!turbo && Math.abs(x) < livezone && Math.abs(y) < livezone) {
+      this.dpadCanFire = true;
+    }
+    if (!this.dpadCanFire) return;
+
+    const deadzoneFilteredX = Math.abs(x) < deadzone ? 0 : x;
+    const deadzoneFilteredY = Math.abs(y) < deadzone ? 0 : y;
+    if (deadzoneFilteredX == 0 && deadzoneFilteredY == 0) return;
+    const angle = Math.atan2(deadzoneFilteredX, deadzoneFilteredY);
+    const direction =
+      directions === 4 ? angleTo4Direction(angle) : angleTo8Direction(angle);
+
+    event.target.emit(`${hand}_dpad_${direction}`);
+    event.target.emit(`${hand}_haptic_pulse`, { intensity: haptic_intensity });
+    if (!turbo) {
+      this.dpadCanFire = false;
+    }
+  }
+});
diff --git a/src/components/split-axis-events.js b/src/components/split-axis-events.js
index e78e8115906b1f9b1bc19dbd3b412b78a1eb5ab5..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644
--- a/src/components/split-axis-events.js
+++ b/src/components/split-axis-events.js
@@ -1,376 +0,0 @@
-const angleTo4Direction = function(angle) {
-  angle = (angle * THREE.Math.RAD2DEG + 180 + 45) % 360;
-  if (angle > 0 && angle < 90) {
-    return "north";
-  } else if (angle >= 90 && angle < 180) {
-    return "west";
-  } else if (angle >= 180 && angle < 270) {
-    return "south";
-  } else {
-    return "east";
-  }
-};
-
-const angleTo8Direction = function(angle) {
-  angle = (angle * THREE.Math.RAD2DEG + 180 + 45) % 360;
-  var direction = "";
-  if ((angle >= 0 && angle < 120) || angle >= 330) {
-    direction += "north";
-  }
-  if (angle >= 150 && angle < 300) {
-    direction += "south";
-  }
-  if (angle >= 60 && angle < 210) {
-    direction += "west";
-  }
-  if ((angle >= 240 && angle < 360) || angle < 30) {
-    direction += "east";
-  }
-  return direction;
-};
-
-AFRAME.registerComponent("dpad-as-axes", {
-  schema: {
-    inputName: { default: "dpad" },
-    name: { default: "dpad_axes" },
-    emitter: { default: "#left-hand" }
-  },
-
-  init: function() {
-    this.mapping = [
-      {
-        direction: "north",
-        axes: [0, 1]
-      },
-      {
-        direction: "northeast",
-        axes: [1, 1]
-      },
-      {
-        direction: "east",
-        axes: [1, 0]
-      },
-      {
-        direction: "southeast",
-        axes: [1, -1]
-      },
-      {
-        direction: "south",
-        axes: [0, -1]
-      },
-      {
-        direction: "southwest",
-        axes: [-1, -1]
-      },
-      {
-        direction: "west",
-        axes: [-1, 0]
-      },
-      {
-        direction: "northwest",
-        axes: [-1, 1]
-      }
-    ];
-    this.handlers = [];
-  },
-
-  play: function() {
-    var inputName = this.data.inputName;
-    for (var pair of this.mapping) {
-      this.handlers[pair.direction] = this.emitAxes(pair.axes).bind(this);
-      this.el.addEventListener(
-        `${inputName}_${pair.direction}`,
-        this.handlers[pair.direction]
-      );
-    }
-  },
-
-  pause: function() {
-    var inputName = this.data.inputName;
-    for (var pair of this.mapping) {
-      this.el.removeEventListener(
-        `${inputName}_${pair.direction}`,
-        this.handlers[pair.direction]
-      );
-    }
-  },
-
-  emitAxes: function(axes) {
-    const name = this.data.name;
-    const inputName = this.data.inputName;
-    const emitter = document.querySelector(this.data.emitter);
-    return function(event) {
-      emitter.emit(name, { axis: [axes[0], axes[1]] });
-    };
-  }
-});
-
-AFRAME.registerComponent("wasd-dpad", {
-  schema: {
-    north: { default: "w" },
-    east: { default: "d" },
-    south: { default: "s" },
-    west: { default: "a" }
-  },
-
-  init: function() {
-    this.onKeyPress = this.onKeyPress.bind(this);
-    this.onKeyUp = this.onKeyUp.bind(this);
-    this.keys = {};
-  },
-
-  play: function() {
-    window.addEventListener("keypress", this.onKeyPress);
-    window.addEventListener("keyup", this.onKeyUp);
-  },
-
-  pause: function() {
-    window.remove("keypress", this.onKeyPress);
-    window.remove("keyup", this.onKeyUp);
-  },
-
-  tick: function(t, dt) {
-    const { north, east, south, west } = this.data;
-    var direction = "";
-    direction += this.keys[north] ? "north" : "";
-    direction += this.keys[south] ? "south" : "";
-    direction += this.keys[east] ? "east" : "";
-    direction += this.keys[west] ? "west" : "";
-    if (direction !== "") {
-      this.el.emit(`dpad_${direction}`);
-    }
-  },
-
-  onKeyPress: function(event) {
-    const { north, east, south, west } = this.data;
-    for (var dir of [north, south, east, west]) {
-      if (event.key === dir) {
-        this.keys[dir] = true;
-      }
-    }
-  },
-
-  onKeyUp: function(event) {
-    const { north, east, south, west } = this.data;
-    for (var dir of [north, south, east, west]) {
-      if (event.key === dir) {
-        this.keys[dir] = false;
-      }
-    }
-  }
-});
-
-AFRAME.registerComponent("oculus-touch-controls-extended", {
-  schema: {
-    hand: { default: "left" },
-    dpad: { default: false },
-    dpad_deadzone: { default: 0.85 },
-    dpad_livezone: { default: 0.35 },
-    dpad_directions: { default: 4 }, // one of [4, 8]
-    dpad_turbo: { default: false },
-    dpad_haptic_intensity: { default: "none" } // one of ["none", "low", "mid", "high"]
-  },
-
-  init: function() {
-    this.axisToDpad = this.axisToDpad.bind(this);
-    this.dpadCanFire = true;
-  },
-
-  update: function(old) {
-    if (old.dpad && !this.data.dpad) {
-      this.el.removeEventListener("axismove", this.axisToDpad);
-    }
-    if (!old.dpad && this.data.dpad) {
-      this.el.addEventListener("axismove", this.axisToDpad);
-    }
-  },
-
-  axisToDpad: function(event) {
-    var x = event.detail.axis[0];
-    var y = event.detail.axis[1];
-    var deadzone = this.data.dpad_deadzone;
-    var turbo = this.data.dpad_turbo;
-    var livezone = this.data.dpad_livezone;
-    var directions = this.data.dpad_directions;
-    var haptic_intensity = this.data.dpad_haptic_intensity;
-    var hand = this.data.hand;
-
-    event.target.emit(`${hand}_axismove`, {
-      axis: [event.detail.axis[0], -event.detail.axis[1]]
-    });
-
-    if (!turbo && Math.abs(x) < livezone && Math.abs(y) < livezone) {
-      this.dpadCanFire = true;
-    }
-    if (!this.dpadCanFire) return;
-
-    x = Math.abs(x) < deadzone ? 0 : x;
-    y = Math.abs(y) < deadzone ? 0 : y;
-    if (x == 0 && y == 0) return;
-    var angle = Math.atan2(x, y);
-    var direction =
-      directions === 4 ? angleTo4Direction(angle) : angleTo8Direction(angle);
-
-    event.target.emit(`${hand}_dpad_${direction}`);
-    event.target.emit(`${hand}_haptic_pulse`, { intensity: haptic_intensity });
-    if (!turbo) {
-      this.dpadCanFire = false;
-    }
-  }
-});
-
-AFRAME.registerComponent("haptic-feedback", {
-  schema: {
-    hand: { default: "left" }
-  },
-
-  init: function() {
-    this.pulse = this.pulse.bind(this);
-    this.tryGetActuator = this.tryGetActuator.bind(this);
-    this.tryGetActuator();
-  },
-
-  tryGetActuator() {
-    var trackedControls = this.el.components["tracked-controls"];
-    if (trackedControls && trackedControls.controller) {
-      this.actuator = trackedControls.controller.hapticActuators[0];
-    } else {
-      setTimeout(this.tryGetActuator, 1000);
-    }
-  },
-
-  play: function() {
-    this.el.addEventListener(`${this.data.hand}_haptic_pulse`, this.pulse);
-  },
-  pause: function() {
-    this.el.removeEventListener(`${this.data.hand}_haptic_pulse`, this.pulse);
-  },
-
-  pulse: function(event) {
-    let { strength, duration, intensity } = event.detail;
-    if (intensity === "low") {
-      strength = 0.07;
-      duration = 12;
-    }
-    if (intensity === "medium") {
-      strength = 0.2;
-      duration = 12;
-    }
-    if (intensity === "high") {
-      strength = 1;
-      duration = 12;
-    }
-    if (intensity === "none") {
-      return;
-    }
-
-    this.actuator.pulse(strength, duration);
-  }
-});
-
-AFRAME.registerComponent("vive-controls-extended", {
-  schema: {
-    hand: { default: "left" },
-    dpad: { default: false },
-    dpad_livezone: { default: 0.3 },
-    dpad_deadzone: { default: 0.8 },
-    dpad_directions: { default: 4 },
-    dpad_turbo: { default: true },
-    dpad_pressed_turbo: { default: false },
-    center_zone: { default: 0.3 },
-    dpad_haptic_intensity: { default: "none" },
-    dpad_pressed_haptic_intensity: { default: "none" }
-  },
-
-  init: function() {
-    this.dpadCanFire = true;
-    this.dpadPressedCanFire = true;
-    this.trackpadPressed = false;
-    this.onAxisMove = this.onAxisMove.bind(this);
-    this.lastSeenAxes = [0, 0];
-    this.onButtonChanged = this.onButtonChanged.bind(this);
-  },
-
-  play: function() {
-    this.el.addEventListener("axismove", this.onAxisMove);
-    this.el.addEventListener("trackpadchanged", this.onButtonChanged);
-  },
-
-  pause: function() {
-    this.el.removeEventListener("axismove", this.onAxisMove);
-    this.el.removeEventListener("trackpadchanged", this.onButtonChanged);
-  },
-
-  onAxisMove: function(event) {
-    var x = event.detail.axis[0];
-    var y = event.detail.axis[1];
-    this.lastSeenAxes = [x, y];
-    var hand = this.data.hand;
-    const axisMoveEvent = `${hand}_trackpad${this.trackpadPressed
-      ? "_pressed"
-      : ""}_axismove`;
-    this.el.emit(axisMoveEvent, {
-      axis: [x, y]
-    });
-
-    var deadzone = this.data.dpad_deadzone;
-    var turbo = this.data.dpad_turbo;
-    var pressedTurbo = this.data.dpad_pressed_turbo;
-    var livezone = this.data.dpad_livezone;
-    var directions = this.data.dpad_directions;
-    var hapticIntensity = this.data.dpad_haptic_intensity;
-    var pressedHapticIntensity = this.data.dpad_pressed_haptic_intensity;
-
-    if (!turbo && Math.abs(x) < livezone && Math.abs(y) < livezone) {
-      this.dpadCanFire = true;
-    }
-
-    event.target.emit(`${hand}_haptic_pulse`, {
-      intensity: this.trackpadPressed ? pressedHapticIntensity : hapticIntensity
-    });
-
-    x = Math.abs(x) < deadzone ? 0 : x;
-    y = Math.abs(y) < deadzone ? 0 : y;
-    if (x == 0 && y == 0) return;
-    var angle = Math.atan2(x, y);
-    var direction =
-      directions === 4 ? angleTo4Direction(angle) : angleTo8Direction(angle);
-
-    if (!this.trackpadPressed && !this.dpadCanFire) {
-      return;
-    }
-    if (this.trackpadPressed && !this.dpadPressedCanFire) {
-      return;
-    }
-    var dpadEvent = `${hand}_trackpad_dpad${this.trackpadPressed
-      ? "_pressed"
-      : ""}_${direction}`;
-    event.target.emit(dpadEvent);
-
-    if (!this.trackpadPressed && !turbo) {
-      this.dpadCanFire = false;
-    } else if (this.trackpadPressed && !pressedTurbo) {
-      this.dpadPressedCanFire = false;
-    }
-  },
-
-  onButtonChanged: function(event) {
-    const x = this.lastSeenAxes[0];
-    const y = this.lastSeenAxes[1];
-    const hand = this.data.hand;
-    const centerZone = this.data.center_zone;
-    const down = !this.trackpadPressed && event.detail.pressed;
-    const up = this.trackpadPressed && !event.detail.pressed;
-    const center =
-      Math.abs(x) < centerZone && Math.abs(y) < centerZone ? "_center" : "";
-    const eventName = `${hand}_trackpad${center}${up ? "_up" : ""}${down
-      ? "_down"
-      : ""}`;
-    this.el.emit(eventName);
-    if (up) {
-      this.dpadPressedCanFire = true;
-    }
-
-    this.trackpadPressed = event.detail.pressed;
-  }
-});
diff --git a/src/components/virtual-gamepad-controls.js b/src/components/virtual-gamepad-controls.js
index afa01aa81d3de5bf80d951b9b65c3f40a0c80c38..d399e38b29765280a677bb23289cdc3e4c2aff6b 100644
--- a/src/components/virtual-gamepad-controls.js
+++ b/src/components/virtual-gamepad-controls.js
@@ -53,10 +53,10 @@ AFRAME.registerComponent("virtual-gamepad-controls", {
   onJoystickChanged(event, joystick) {
     if (event.target.id === this.leftStick.id) {
       if (event.type === "move") {
-        var angle = joystick.angle.radian;
-        var force = joystick.force < 1 ? joystick.force : 1;
-        var x = Math.cos(angle) * force;
-        var z = Math.sin(angle) * force;
+        const angle = joystick.angle.radian;
+        const force = joystick.force < 1 ? joystick.force : 1;
+        const x = Math.cos(angle) * force;
+        const z = Math.sin(angle) * force;
         this.el.sceneEl.emit("move", { axis: [x, z] });
       } else {
         this.el.sceneEl.emit("move", { axis: [0, 0] });
diff --git a/src/components/vive-controls-extended.js b/src/components/vive-controls-extended.js
new file mode 100644
index 0000000000000000000000000000000000000000..7dfde12a0db85752c8545a69a8d983cb84d0d7b1
--- /dev/null
+++ b/src/components/vive-controls-extended.js
@@ -0,0 +1,105 @@
+import { angleTo4Direction, angleTo8Direction } from "../utils";
+
+AFRAME.registerComponent("vive-controls-extended", {
+  schema: {
+    hand: { type: "string" },
+    dpad_enabled: { default: false },
+    dpad_livezone: { default: 0.3 },
+    dpad_deadzone: { default: 0.7 },
+    dpad_directions: { default: 4 },
+    dpad_turbo: { default: false },
+    dpad_pressed_turbo: { default: false },
+    center_zone: { default: 0.3 },
+    dpad_haptic_intensity: { default: "none" },
+    dpad_pressed_haptic_intensity: { default: "none" }
+  },
+
+  init: function() {
+    this.dpadCanFire = true;
+    this.dpadPressedCanFire = true;
+    this.trackpadPressed = false;
+    this.onAxisMove = this.onAxisMove.bind(this);
+    this.lastSeenAxes = [0, 0];
+    this.onButtonChanged = this.onButtonChanged.bind(this);
+  },
+
+  play: function() {
+    this.el.addEventListener("axismove", this.onAxisMove);
+    this.el.addEventListener("trackpadchanged", this.onButtonChanged);
+  },
+
+  pause: function() {
+    this.el.removeEventListener("axismove", this.onAxisMove);
+    this.el.removeEventListener("trackpadchanged", this.onButtonChanged);
+  },
+
+  onAxisMove: function(event) {
+    const x = event.detail.axis[0];
+    const y = event.detail.axis[1];
+    this.lastSeenAxes = [x, y];
+    const hand = this.data.hand;
+    const pressed = this.trackpadPressed ? "_pressed" : "";
+    const axisMoveEvent = `${hand}_trackpad${pressed}_axismove`;
+    this.el.emit(axisMoveEvent, {
+      axis: [x, y]
+    });
+
+    const deadzone = this.data.dpad_deadzone;
+    const turbo = this.data.dpad_turbo;
+    const pressedTurbo = this.data.dpad_pressed_turbo;
+    const livezone = this.data.dpad_livezone;
+    const directions = this.data.dpad_directions;
+    const hapticIntensity = this.data.dpad_haptic_intensity;
+    const pressedHapticIntensity = this.data.dpad_pressed_haptic_intensity;
+
+    if (!turbo && Math.abs(x) < livezone && Math.abs(y) < livezone) {
+      this.dpadCanFire = true;
+    }
+
+    event.target.emit(`${hand}_haptic_pulse`, {
+      intensity: this.trackpadPressed ? pressedHapticIntensity : hapticIntensity
+    });
+
+    const deadzoneFilteredX = Math.abs(x) < deadzone ? 0 : x;
+    const deadzoneFilteredY = Math.abs(y) < deadzone ? 0 : y;
+    if (deadzoneFilteredX == 0 && deadzoneFilteredY == 0) return;
+    const angle = Math.atan2(deadzoneFilteredX, deadzoneFilteredY);
+    const direction =
+      directions === 4 ? angleTo4Direction(angle) : angleTo8Direction(angle);
+
+    if (!this.trackpadPressed && !this.dpadCanFire) {
+      return;
+    }
+    if (this.trackpadPressed && !this.dpadPressedCanFire) {
+      return;
+    }
+    const dpadEvent = `${hand}_trackpad_dpad${pressed}_${direction}`;
+    event.target.emit(dpadEvent);
+
+    if (!this.trackpadPressed && !turbo) {
+      this.dpadCanFire = false;
+    } else if (this.trackpadPressed && !pressedTurbo) {
+      this.dpadPressedCanFire = false;
+    }
+  },
+
+  onButtonChanged: function(event) {
+    const x = this.lastSeenAxes[0];
+    const y = this.lastSeenAxes[1];
+    const hand = this.data.hand;
+    const centerZone = this.data.center_zone;
+    const down = !this.trackpadPressed && event.detail.pressed;
+    const up = this.trackpadPressed && !event.detail.pressed;
+    const center =
+      Math.abs(x) < centerZone && Math.abs(y) < centerZone ? "_center" : "";
+    const eventName = `${hand}_trackpad${center}${up ? "_up" : ""}${down
+      ? "_down"
+      : ""}`;
+    this.el.emit(eventName);
+    if (up) {
+      this.dpadPressedCanFire = true;
+    }
+
+    this.trackpadPressed = event.detail.pressed;
+  }
+});
diff --git a/src/room.js b/src/room.js
index 2330ffd76d063980a44352fb51430ea34ec35845..b660adffec6884491d67024f96ba73d781781eb7 100644
--- a/src/room.js
+++ b/src/room.js
@@ -20,6 +20,11 @@ import "./components/body-controller";
 import "./components/hand-controls2";
 import "./components/character-controller";
 import "./components/split-axis-events";
+import "./components/dpad-as-axes";
+import "./components/keyboard-dpad";
+import "./components/oculus-touch-controls-extended";
+import "./components/vive-controls-extended";
+import "./components/haptic-feedback";
 import "./components/networked-video-player";
 import "./components/offset-relative-to";
 import "./components/cached-gltf-model";
diff --git a/src/utils.js b/src/utils.js
index b37764120a388120c89392fb6b9b97f31dd59578..1c09c032f010a84eddfa67ad7197a3d3bc92b603 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -167,9 +167,8 @@ export function generateName() {
 }
 
 export function promptForName(username) {
-  if (!username)
-    username = generateName();
-  
+  if (!username) username = generateName();
+
   do {
     username = prompt("Choose a username", username);
   } while (!(username && username.length));
@@ -179,11 +178,59 @@ export function promptForName(username) {
 export function getCookie(name) {
   var value = "; " + document.cookie;
   var parts = value.split("; " + name + "=");
-  if (parts.length == 2) return parts.pop().split(";").shift();
+  if (parts.length == 2)
+    return parts
+      .pop()
+      .split(";")
+      .shift();
 }
 
-export function parseJwt (token) {
-  var base64Url = token.split('.')[1];
-  var base64 = base64Url.replace('-', '+').replace('_', '/');
+export function parseJwt(token) {
+  var base64Url = token.split(".")[1];
+  var base64 = base64Url.replace("-", "+").replace("_", "/");
   return JSON.parse(window.atob(base64));
 }
+
+export function angleToDirection(angle) {
+  angle = (angle * THREE.Math.RAD2DEG + 180 + 45) % 360;
+  if (angle > 0 && angle < 90) {
+    return "down";
+  } else if (angle >= 90 && angle < 180) {
+    return "left";
+  } else if (angle >= 180 && angle < 270) {
+    return "up";
+  } else {
+    return "right";
+  }
+}
+
+export function angleTo4Direction(angle) {
+  angle = (angle * THREE.Math.RAD2DEG + 180 + 45) % 360;
+  if (angle > 0 && angle < 90) {
+    return "north";
+  } else if (angle >= 90 && angle < 180) {
+    return "west";
+  } else if (angle >= 180 && angle < 270) {
+    return "south";
+  } else {
+    return "east";
+  }
+}
+
+export function angleTo8Direction(angle) {
+  angle = (angle * THREE.Math.RAD2DEG + 180 + 45) % 360;
+  var direction = "";
+  if ((angle >= 0 && angle < 120) || angle >= 330) {
+    direction += "north";
+  }
+  if (angle >= 150 && angle < 300) {
+    direction += "south";
+  }
+  if (angle >= 60 && angle < 210) {
+    direction += "west";
+  }
+  if ((angle >= 240 && angle < 360) || angle < 30) {
+    direction += "east";
+  }
+  return direction;
+}
diff --git a/templates/room.hbs b/templates/room.hbs
index 8371574e69255d44343dd814a8ec0418d89971fa..5234c5de1181e98ed34618c75f1b18a343b01c0b 100644
--- a/templates/room.hbs
+++ b/templates/room.hbs
@@ -122,8 +122,6 @@
             networked
             spawn-controller="radius: 4;"
             character-controller="pivot: #head"
-            wasd-dpad
-            dpad-as-axes="inputName:dpad; name:keyboard_dpad_axes; emitter: #left-hand"
         >
             <a-sphere scale="0.1 0.1 0.1"></a-sphere>
 
@@ -149,22 +147,19 @@
                 id="left-hand"
                 hand-controls2="left"
                 tracked-controls
-                haptic-feedback="hand:left;"
+                haptic-feedback="hapticEventName: left_haptic_pulse;"
                 vive-controls-extended="
-                    hand:left;
-                    dpad_haptic_intensity: none;
-                    dpad_deadzone: 0.6;
+                    hand: left;
+                    dpad_turbo: true;
                     dpad_pressed_haptic_intensity: low"
                 oculus-touch-controls-extended="
-                    dpad: true;
-                    dpad_deadzone: 0.8;
-                    dpad_livezone: 0.3;
+                    dpad_enabled: true;
                     dpad_directions: 8;
                     dpad_turbo: true;
                     dpad_haptic_intensity: none"
                 dpad-as-axes="
-                    inputName:left_dpad;
-                    name:left_dpad_axes;
+                    dpadActionPrefix:left_dpad;
+                    analog2dOutputAction:left_dpad_axes;
                     emitter: #left-hand"
                 teleport-controls="cameraRig: #player-rig; teleportOrigin: #head; button: action_teleport_"
                 networked="template: #left-hand-template;"
@@ -182,24 +177,16 @@
             <a-entity
                 id="right-hand"
                 hand-controls2="right"
-                haptic-feedback="hand:right;"
+                haptic-feedback="hapticEventName: right_haptic_pulse;"
                 vive-controls-extended="
                     hand:right;
-                    dpad: true;
+                    dpad_enabled: true;
                     dpad_livezone: 1.0;
                     dpad_deadzone: 0.6;
-                    dpad_directions: 4;
-                    dpad_turbo: false;
-                    dpad_haptic_intensity: none;
                     dpad_pressed_haptic_intensity: low;"
                 oculus-touch-controls-extended="
                     hand:right;
-                    dpad: true;
-                    dpad_livezone: 0.3;
-                    dpad_deadzone: 0.8;
-                    dpad_directions: 4;
-                    dpad_turbo: false;
-                    dpad_haptic_intensity: none"
+                    dpad_enabled: true;"
                 teleport-controls="cameraRig: #player-rig;
                                     teleportOrigin: #head;
                                     hitEntity: #telepor-indicator;