diff --git a/src/components/cursor-controller.js b/src/components/cursor-controller.js
index e147c6bf4e5e7097b532e42a5d955a27ff30ddc5..0947f60406f4f3d651b9134939c115d142a61013 100644
--- a/src/components/cursor-controller.js
+++ b/src/components/cursor-controller.js
@@ -3,383 +3,190 @@ const TARGET_TYPE_INTERACTABLE = 2;
 const TARGET_TYPE_UI = 4;
 const TARGET_TYPE_INTERACTABLE_OR_UI = TARGET_TYPE_INTERACTABLE | TARGET_TYPE_UI;
 
-/**
- * Controls virtual cursor behavior in various modalities to affect teleportation, interatables and UI.
- * @namespace user-input
- * @component cursor-controller
- */
 AFRAME.registerComponent("cursor-controller", {
   dependencies: ["raycaster", "line"],
   schema: {
     cursor: { type: "selector" },
     camera: { type: "selector" },
-    playerRig: { type: "selector" },
-    gazeTeleportControls: { type: "selector" },
-    physicalHandSelector: { type: "string" },
-    handedness: { default: "right", oneOf: ["right", "left"] },
     maxDistance: { default: 3 },
-    minDistance: { default: 0.5 },
+    minDistance: { default: 0 },
     cursorColorHovered: { default: "#2F80ED" },
     cursorColorUnhovered: { default: "#FFFFFF" },
-    primaryDown: { default: "action_primary_down" },
-    primaryUp: { default: "action_primary_up" },
-    grabEvent: { default: "action_grab" },
-    releaseEvent: { default: "action_release" }
+    rayObject: { type: "selector" },
+    useMousePos: { default: true },
+    drawLine: { default: false }
   },
 
   init: function() {
+    this.enabled = true;
     this.inVR = false;
     this.isMobile = AFRAME.utils.device.isMobile();
-    this.hasPointingDevice = false;
     this.currentTargetType = TARGET_TYPE_NONE;
-    this.grabStarting = false;
     this.currentDistance = this.data.maxDistance;
     this.currentDistanceMod = 0;
     this.mousePos = new THREE.Vector2();
-    this.controller = null;
-    this.controllerQueue = [];
     this.wasCursorHovered = false;
-    this.wasPhysicalHandGrabbing = false;
     this.origin = new THREE.Vector3();
     this.direction = new THREE.Vector3();
     this.controllerQuaternion = new THREE.Quaternion();
 
     this.data.cursor.setAttribute("material", { color: this.data.cursorColorUnhovered });
 
-    window.APP.touchEventsHandler.registerCursor(this);
-    window.APP.touchEventsHandler.registerPinchEmitter(this.el);
-    this.handleTouchStart = this.handleTouchStart.bind(this);
-    this.handleTouchMove = this.handleTouchMove.bind(this);
-    this.handleTouchEnd = this.handleTouchEnd.bind(this);
+    this.forceCursorUpdate = this.forceCursorUpdate.bind(this);
+    this.startInteraction = this.startInteraction.bind(this);
+    this.moveCursor = this.moveCursor.bind(this);
+    this.endInteraction = this.endInteraction.bind(this);
+    this.changeDistanceMod = this.changeDistanceMod.bind(this);
+    this.setRaycasterWithMousePos = this.setRaycasterWithMousePos.bind(this);
+    this.updateDistanceAndTargetType = this.updateDistanceAndTargetType.bind(this);
 
-    window.APP.mouseEventsHandler.registerCursor(this);
-    this.handleMouseDown = this.handleMouseDown.bind(this);
-    this.handleMouseMove = this.handleMouseMove.bind(this);
-    this.handleMouseUp = this.handleMouseUp.bind(this);
-    this.handleMouseWheel = this.handleMouseWheel.bind(this);
-
-    this._handleEnterVR = this._handleEnterVR.bind(this);
-    this._handleExitVR = this._handleExitVR.bind(this);
-    this._handlePrimaryDown = this._handlePrimaryDown.bind(this);
-    this._handlePrimaryUp = this._handlePrimaryUp.bind(this);
-    this._handleModelLoaded = this._handleModelLoaded.bind(this);
     this._handleCursorLoaded = this._handleCursorLoaded.bind(this);
-    this._handleControllerConnected = this._handleControllerConnected.bind(this);
-    this._handleControllerDisconnected = this._handleControllerDisconnected.bind(this);
-
     this.data.cursor.addEventListener("loaded", this._handleCursorLoaded);
   },
 
-  remove: function() {
-    this.data.cursor.removeEventListener("loaded", this._handleCursorLoaded);
+  enable: function() {
+    this.enabled = true;
   },
 
-  update: function(oldData) {
-    if (oldData.physicalHandSelector !== this.data.physicalHandSelector) {
-      this._handleModelLoaded();
-    }
-
-    if (oldData.handedness !== this.data.handedness) {
-      //TODO
-    }
+  disable: function() {
+    this.enabled = false;
+    this.setCursorVisibility(false);
   },
 
-  play: function() {
-    window.addEventListener("enter-vr", this._handleEnterVR);
-    window.addEventListener("exit-vr", this._handleExitVR);
-
-    this.data.playerRig.addEventListener(this.data.primaryDown, this._handlePrimaryDown);
-    this.data.playerRig.addEventListener(this.data.primaryUp, this._handlePrimaryUp);
-    this.data.playerRig.addEventListener(this.data.grabEvent, this._handlePrimaryDown);
-    this.data.playerRig.addEventListener(this.data.releaseEvent, this._handlePrimaryUp);
-    this.data.playerRig.addEventListener("cardboardbuttondown", this._handlePrimaryDown);
-    this.data.playerRig.addEventListener("cardboardbuttonup", this._handlePrimaryUp);
-    this.data.playerRig.addEventListener("model-loaded", this._handleModelLoaded);
-
-    this.el.sceneEl.addEventListener("controllerconnected", this._handleControllerConnected);
-    this.el.sceneEl.addEventListener("controllerdisconnected", this._handleControllerDisconnected);
+  update: function() {
+    if (this.data.rayObject) {
+      this.rayObject = this.data.rayObject.object3D;
+    }
   },
 
-  pause: function() {
-    window.removeEventListener("enter-vr", this._handleEnterVR);
-    window.removeEventListener("exit-vr", this._handleExitVR);
-
-    this.data.playerRig.removeEventListener(this.data.primaryDown, this._handlePrimaryDown);
-    this.data.playerRig.removeEventListener(this.data.primaryUp, this._handlePrimaryUp);
-    this.data.playerRig.removeEventListener(this.data.grabEvent, this._handlePrimaryDown);
-    this.data.playerRig.removeEventListener(this.data.releaseEvent, this._handlePrimaryUp);
-    this.data.playerRig.removeEventListener("cardboardbuttondown", this._handlePrimaryDown);
-    this.data.playerRig.removeEventListener("cardboardbuttonup", this._handlePrimaryUp);
+  tick: (() => {
+    const rayObjectRotation = new THREE.Quaternion();
 
-    this.data.playerRig.removeEventListener("model-loaded", this._handleModelLoaded);
+    return function() {
+      if (!this.enabled) {
+        return;
+      }
 
-    this.el.sceneEl.removeEventListener("controllerconnected", this._handleControllerConnected);
-    this.el.sceneEl.removeEventListener("controllerdisconnected", this._handleControllerDisconnected);
-  },
+      if (this.data.useMousePos) {
+        this.setRaycasterWithMousePos();
+      } else {
+        //this.rayObject.updateMatrixWorld();
+        rayObjectRotation.setFromRotationMatrix(this.rayObject.matrixWorld);
+        this.direction
+          .set(0, 0, 1)
+          .applyQuaternion(rayObjectRotation)
+          .normalize();
+        this.origin.setFromMatrixPosition(this.rayObject.matrixWorld);
+      }
+      this.el.setAttribute("raycaster", { origin: this.origin, direction: this.direction });
+
+      const isGrabbing = this.data.cursor.components["super-hands"].state.has("grab-start");
+      if (isGrabbing) {
+        const distance = Math.min(
+          this.data.maxDistance,
+          Math.max(this.data.minDistance, this.currentDistance - this.currentDistanceMod)
+        );
+        this.direction.multiplyScalar(distance);
+        this.data.cursor.object3D.position.addVectors(this.origin, this.direction);
+      } else {
+        this.currentDistanceMod = 0;
+        this.updateDistanceAndTargetType();
+
+        const isTarget = this._isTargetOfType(TARGET_TYPE_INTERACTABLE_OR_UI);
+        if (isTarget && !this.wasCursorHovered) {
+          this.wasCursorHovered = true;
+          this.data.cursor.setAttribute("material", { color: this.data.cursorColorHovered });
+        } else if (!isTarget && this.wasCursorHovered) {
+          this.wasCursorHovered = false;
+          this.data.cursor.setAttribute("material", { color: this.data.cursorColorUnhovered });
+        }
+      }
 
-  tick: function() {
-    //handle physical hand
-    if (this.physicalHand) {
-      const state = this.physicalHand.components["super-hands"].state;
-      const isPhysicalHandGrabbing = state.has("grab-start") || state.has("hover-start");
-      if (this.wasPhysicalHandGrabbing != isPhysicalHandGrabbing) {
-        this._setCursorVisibility(!isPhysicalHandGrabbing);
-        this.currentTargetType = TARGET_TYPE_NONE;
+      if (this.data.drawLine) {
+        this.el.setAttribute("line", { start: this.origin.clone(), end: this.data.cursor.object3D.position.clone() });
       }
-      this.wasPhysicalHandGrabbing = isPhysicalHandGrabbing;
-      if (isPhysicalHandGrabbing) return;
-    }
+    };
+  })(),
 
-    //set raycaster origin/direction
+  setRaycasterWithMousePos() {
     const camera = this.data.camera.components.camera.camera;
-    if (!this.inVR) {
-      //mouse cursor mode
-      const raycaster = this.el.components.raycaster.raycaster;
-      raycaster.setFromCamera(this.mousePos, camera);
-      this.origin.copy(raycaster.ray.origin);
-      this.direction.copy(raycaster.ray.direction);
-    } else if ((this.inVR || this.isMobile) && !this.hasPointingDevice) {
-      //gaze cursor mode
-      camera.getWorldPosition(this.origin);
-      camera.getWorldDirection(this.direction);
-    } else if (this.controller != null) {
-      //3d cursor mode
-      this.controller.object3D.getWorldPosition(this.origin);
-      this.controller.object3D.getWorldQuaternion(this.controllerQuaternion);
-      this.direction
-        .set(0, 0, -1)
-        .applyQuaternion(this.controllerQuaternion)
-        .normalize();
-    }
-
-    this.el.setAttribute("raycaster", { origin: this.origin, direction: this.direction });
+    const raycaster = this.el.components.raycaster.raycaster;
+    raycaster.setFromCamera(this.mousePos, camera);
+    this.origin.copy(raycaster.ray.origin);
+    this.direction.copy(raycaster.ray.direction);
+    this.el.setAttribute("raycaster", { origin: raycaster.ray.origin, direction: raycaster.ray.direction });
+  },
 
+  updateDistanceAndTargetType() {
     let intersection = null;
-
-    //update cursor position
-    if (!this._isGrabbing()) {
-      const intersections = this.el.components.raycaster.intersections;
-      if (intersections.length > 0 && intersections[0].distance <= this.data.maxDistance) {
-        intersection = intersections[0];
-        this.data.cursor.object3D.position.copy(intersection.point);
-        this.currentDistance = intersections[0].distance;
-      } else {
-        this.currentDistance = this.data.maxDistance;
-      }
-      this.currentDistanceMod = 0;
-    }
-
-    if (this._isGrabbing() || !intersection) {
-      const max = Math.max(this.data.minDistance, this.currentDistance - this.currentDistanceMod);
-      const distance = Math.min(max, this.data.maxDistance);
-      this.currentDistanceMod = this.currentDistance - distance;
-      this.direction.multiplyScalar(distance);
+    const intersections = this.el.components.raycaster.intersections;
+    if (intersections.length > 0 && intersections[0].distance <= this.data.maxDistance) {
+      intersection = intersections[0];
+      this.data.cursor.object3D.position.copy(intersection.point);
+      this.currentDistance = intersections[0].distance;
+    } else {
+      this.currentDistance = this.data.maxDistance;
+      this.direction.multiplyScalar(this.currentDistance);
       this.data.cursor.object3D.position.addVectors(this.origin, this.direction);
     }
 
-    //update currentTargetType
-    if (this._isGrabbing() && !intersection) {
-      this.currentTargetType = TARGET_TYPE_INTERACTABLE;
-    } else if (intersection) {
-      if (intersection.object.el.matches(".interactable, .interactable *")) {
-        this.currentTargetType = TARGET_TYPE_INTERACTABLE;
-      } else if (intersection.object.el.matches(".ui, .ui *")) {
-        this.currentTargetType = TARGET_TYPE_UI;
-      }
-    } else {
+    if (!intersection) {
       this.currentTargetType = TARGET_TYPE_NONE;
-    }
-
-    //update cursor material
-    const isTarget = this._isTargetOfType(TARGET_TYPE_INTERACTABLE_OR_UI);
-    if ((this._isGrabbing() || isTarget) && !this.wasCursorHovered) {
-      this.wasCursorHovered = true;
-      this.data.cursor.setAttribute("material", { color: this.data.cursorColorHovered });
-    } else if (!this._isGrabbing() && !isTarget && this.wasCursorHovered) {
-      this.wasCursorHovered = false;
-      this.data.cursor.setAttribute("material", { color: this.data.cursorColorUnhovered });
-    }
-
-    //update line
-    if (this.hasPointingDevice) {
-      this.el.setAttribute("line", { start: this.origin.clone(), end: this.data.cursor.object3D.position.clone() });
+    } else if (intersection.object.el.matches(".interactable, .interactable *")) {
+      this.currentTargetType = TARGET_TYPE_INTERACTABLE;
+    } else if (intersection.object.el.matches(".ui, .ui *")) {
+      this.currentTargetType = TARGET_TYPE_UI;
     }
   },
 
-  _isGrabbing() {
-    return this.data.cursor.components["super-hands"].state.has("grab-start");
-  },
-
   _isTargetOfType: function(mask) {
     return (this.currentTargetType & mask) === this.currentTargetType;
   },
 
-  _setCursorVisibility(visible) {
+  setCursorVisibility(visible) {
     this.data.cursor.setAttribute("visible", visible);
-    this.el.setAttribute("line", { visible: visible && this.hasPointingDevice });
-  },
-
-  _startTeleport: function() {
-    if (this.controller != null) {
-      this.controller.emit("cursor-teleport_down", {});
-    } else if (this.inVR) {
-      this.data.gazeTeleportControls.emit("cursor-teleport_down", {});
-    }
-    this._setCursorVisibility(false);
-  },
-
-  _endTeleport: function() {
-    if (this.controller != null) {
-      this.controller.emit("cursor-teleport_up", {});
-    } else if (this.inVR) {
-      this.data.gazeTeleportControls.emit("cursor-teleport_up", {});
-    }
-    this._setCursorVisibility(true);
+    this.el.setAttribute("line", { visible: visible && this.data.drawLine });
   },
 
-  handleTouchStart: function(touch) {
-    // Update the ray and cursor positions
-    const raycasterComp = this.el.components.raycaster;
-    const raycaster = raycasterComp.raycaster;
-    const camera = this.data.camera.components.camera.camera;
-    const cursor = this.data.cursor;
-    this.mousePos.set(touch.clientX / window.innerWidth * 2 - 1, -(touch.clientY / window.innerHeight) * 2 + 1);
-    raycaster.setFromCamera(this.mousePos, camera);
-    this.el.setAttribute("raycaster", { origin: raycaster.ray.origin, direction: raycaster.ray.direction });
-    raycasterComp.checkIntersections();
-    const intersections = raycasterComp.intersections;
-    if (intersections.length === 0 || intersections[0].distance >= this.data.maxDistance) {
-      return;
-    }
-    cursor.object3D.position.copy(intersections[0].point);
-    // Cursor position must be synced to physics before constraint is created
-    cursor.components["static-body"].syncToPhysics();
-    cursor.emit("cursor-grab", {});
-    return true;
-  },
-
-  handleTouchMove: function(touch) {
-    this.mousePos.set(touch.clientX / window.innerWidth * 2 - 1, -(touch.clientY / window.innerHeight) * 2 + 1);
-  },
-
-  handleTouchEnd: function() {
-    this.data.cursor.emit("cursor-release", {});
+  forceCursorUpdate: function() {
+    this.setRaycasterWithMousePos();
+    this.el.components.raycaster.checkIntersections();
+    this.updateDistanceAndTargetType();
+    this.data.cursor.components["static-body"].syncToPhysics();
   },
 
-  handleMouseDown: function() {
+  startInteraction: function() {
     if (this._isTargetOfType(TARGET_TYPE_INTERACTABLE_OR_UI)) {
       this.data.cursor.emit("cursor-grab", {});
       return true;
-    } else if (this.inVR || this.isMobile) {
-      this._startTeleport();
-      return;
     }
     return false;
   },
 
-  handleMouseMove: function(e) {
-    this.mousePos.set(e.clientX / window.innerWidth * 2 - 1, -(e.clientY / window.innerHeight) * 2 + 1);
+  moveCursor: function(x, y) {
+    this.mousePos.set(x, y);
   },
 
-  handleMouseUp: function() {
+  endInteraction: function() {
     this.data.cursor.emit("cursor-release", {});
-    this._endTeleport();
-  },
-
-  handleMouseWheel: function(e) {
-    if (this._isGrabbing()) {
-      switch (e.deltaMode) {
-        case e.DOM_DELTA_PIXEL:
-          this.currentDistanceMod += e.deltaY / 500;
-          break;
-        case e.DOM_DELTA_LINE:
-          this.currentDistanceMod += e.deltaY / 10;
-          break;
-        case e.DOM_DELTA_PAGE:
-          this.currentDistanceMod += e.deltaY / 2;
-          break;
-      }
-    }
-  },
-
-  _handleEnterVR: function() {
-    this.inVR = true;
-    this._updateController();
-  },
-
-  _handleExitVR: function() {
-    this.inVR = false;
-    this._updateController();
-  },
-
-  _handlePrimaryDown: function(e) {
-    if (e.target === this.controller || e.target === this.data.playerRig) {
-      const isInteractable = this._isTargetOfType(TARGET_TYPE_INTERACTABLE) && !this.grabStarting;
-      if (isInteractable || this._isTargetOfType(TARGET_TYPE_UI)) {
-        this.grabStarting = true;
-        this.data.cursor.emit("cursor-grab", e.detail);
-      } else if (e.type !== this.data.grabEvent) {
-        this._startTeleport();
-      }
-    }
   },
 
-  _handlePrimaryUp: function(e) {
-    if (e.target === this.controller || e.target === this.data.playerRig) {
-      this.grabStarting = false;
-      if (this._isGrabbing() || this._isTargetOfType(TARGET_TYPE_UI)) {
-        this.data.cursor.emit("cursor-release", e.detail);
-      } else if (e.type !== this.data.releaseEvent) {
-        this._endTeleport();
-      }
+  changeDistanceMod: function(delta) {
+    const { minDistance, maxDistance } = this.data;
+    const targetDistanceMod = this.currentDistanceMod + delta;
+    const moddedDistance = this.currentDistance - targetDistanceMod;
+    if (moddedDistance > maxDistance || moddedDistance < minDistance) {
+      return;
     }
-  },
-
-  _handleModelLoaded: function() {
-    this.physicalHand = this.data.playerRig.querySelector(this.data.physicalHandSelector);
+    this.currentDistanceMod = targetDistanceMod;
   },
 
   _handleCursorLoaded: function() {
     this.data.cursor.object3DMap.mesh.renderOrder = window.APP.RENDER_ORDER.CURSOR;
+    this.data.cursor.removeEventListener("loaded", this._handleCursorLoaded);
   },
 
-  _handleControllerConnected: function(e) {
-    const data = {
-      controller: e.target,
-      handedness: e.detail.component.data.hand
-    };
-
-    if (data.handedness === this.data.handedness) {
-      this.controllerQueue.unshift(data);
-    } else {
-      this.controllerQueue.push(data);
-    }
-
-    this._updateController();
-  },
-
-  _handleControllerDisconnected: function(e) {
-    for (let i = 0; i < this.controllerQueue.length; i++) {
-      if (e.target === this.controllerQueue[i].controller) {
-        this.controllerQueue.splice(i, 1);
-        this._updateController();
-        return;
-      }
-    }
-  },
-
-  _updateController: function() {
-    this.hasPointingDevice = this.controllerQueue.length > 0 && this.inVR;
-
-    this._setCursorVisibility(this.hasPointingDevice || this.isMobile);
-
-    if (this.hasPointingDevice) {
-      const controllerData = this.controllerQueue[0];
-      const hand = controllerData.handedness;
-      this.el.setAttribute("cursor-controller", { physicalHandSelector: `#player-${hand}-controller` });
-      this.controller = controllerData.controller;
-    } else {
-      this.controller = null;
-    }
+  remove: function() {
+    this.data.cursor.removeEventListener("loaded", this._handleCursorLoaded);
   }
 });
diff --git a/src/components/input-configurator.js b/src/components/input-configurator.js
new file mode 100644
index 0000000000000000000000000000000000000000..a5bcc0cd6cdd72a0105c7ea1d0161368b4fd4cd1
--- /dev/null
+++ b/src/components/input-configurator.js
@@ -0,0 +1,171 @@
+import TouchEventsHandler from "../utils/touch-events-handler.js";
+import MouseEventsHandler from "../utils/mouse-events-handler.js";
+import GearVRMouseEventsHandler from "../utils/gearvr-mouse-events-handler.js";
+import ActionEventHandler from "../utils/action-event-handler.js";
+
+AFRAME.registerComponent("input-configurator", {
+  schema: {
+    cursorController: { type: "selector" },
+    gazeTeleporter: { type: "selector" },
+    camera: { type: "selector" },
+    playerRig: { type: "selector" },
+    leftController: { type: "selector" },
+    rightController: { type: "selector" },
+    leftControllerRayObject: { type: "string" },
+    rightControllerRayObject: { type: "string" },
+    gazeCursorRayObject: { type: "string" }
+  },
+
+  init() {
+    this.inVR = this.el.sceneEl.is("vr-mode");
+    this.isMobile = AFRAME.utils.device.isMobile();
+    this.eventHandlers = [];
+    this.controllerQueue = [];
+    this.hasPointingDevice = false;
+    this.cursor = this.data.cursorController.components["cursor-controller"];
+    this.gazeTeleporter = this.data.gazeTeleporter.components["teleport-controls"];
+    this.cameraController = this.data.camera.components["pitch-yaw-rotator"];
+    this.playerRig = this.data.playerRig;
+    this.handedness = "right";
+
+    this.onEnterVR = this.onEnterVR.bind(this);
+    this.onExitVR = this.onExitVR.bind(this);
+    this.tearDown = this.tearDown.bind(this);
+    this.configureInput = this.configureInput.bind(this);
+    this.addLookOnMobile = this.addLookOnMobile.bind(this);
+    this.handleControllerConnected = this.handleControllerConnected.bind(this);
+    this.handleControllerDisconnected = this.handleControllerDisconnected.bind(this);
+
+    this.configureInput();
+  },
+
+  play() {
+    this.el.sceneEl.addEventListener("controllerconnected", this.handleControllerConnected);
+    this.el.sceneEl.addEventListener("controllerdisconnected", this.handleControllerDisconnected);
+    this.el.sceneEl.addEventListener("enter-vr", this.onEnterVR);
+    this.el.sceneEl.addEventListener("exit-vr", this.onExitVR);
+  },
+
+  pause() {
+    this.el.sceneEl.removeEventListener("controllerconnected", this.handleControllerConnected);
+    this.el.sceneEl.removeEventListener("controllerdisconnected", this.handleControllerDisconnected);
+    this.el.sceneEl.removeEventListener("enter-vr", this.onEnterVR);
+    this.el.sceneEl.removeEventListener("exit-vr", this.onExitVR);
+  },
+
+  onEnterVR() {
+    this.inVR = true;
+    this.tearDown();
+    this.configureInput();
+    this.updateController();
+  },
+
+  onExitVR() {
+    this.inVR = false;
+    this.tearDown();
+    this.configureInput();
+    this.updateController();
+  },
+
+  tearDown() {
+    this.eventHandlers.forEach(h => h.tearDown());
+    this.eventHandlers = [];
+    this.actionEventHandler = null;
+    if (this.lookOnMobile) {
+      this.lookOnMobile.el.removeComponent("look-on-mobile");
+      this.lookOnMobile = null;
+    }
+    this.cursorRequiresManagement = false;
+  },
+
+  addLookOnMobile() {
+    const onAdded = e => {
+      if (e.detail.name !== "look-on-mobile") return;
+      this.lookOnMobile = this.el.sceneEl.components["look-on-mobile"];
+    };
+    this.el.sceneEl.addEventListener("componentinitialized", onAdded);
+    // This adds look-on-mobile to the scene
+    this.el.sceneEl.setAttribute("look-on-mobile", "camera", this.data.camera);
+  },
+
+  configureInput() {
+    this.actionEventHandler = new ActionEventHandler(this.el.sceneEl, this.cursor);
+    this.eventHandlers.push(this.actionEventHandler);
+
+    this.cursor.el.setAttribute("cursor-controller", "useMousePos", !this.inVR);
+
+    if (this.inVR) {
+      this.cameraController.pause();
+      this.cursorRequiresManagement = true;
+      this.cursor.el.setAttribute("cursor-controller", "minDistance", 0);
+      if (this.isMobile) {
+        this.eventHandlers.push(new GearVRMouseEventsHandler(this.cursor, this.gazeTeleporter));
+      } else {
+        this.eventHandlers.push(new MouseEventsHandler(this.cursor, this.cameraController));
+      }
+    } else {
+      this.cameraController.play();
+      if (this.isMobile) {
+        this.eventHandlers.push(new TouchEventsHandler(this.cursor, this.cameraController, this.cursor.el));
+        this.addLookOnMobile();
+      } else {
+        this.eventHandlers.push(new MouseEventsHandler(this.cursor, this.cameraController));
+        this.cursor.el.setAttribute("cursor-controller", "minDistance", 0.3);
+      }
+    }
+  },
+
+  tick() {
+    if (this.cursorRequiresManagement && this.controller) {
+      this.actionEventHandler.manageCursorEnabled();
+    }
+  },
+
+  handleControllerConnected: function(e) {
+    const data = {
+      controller: e.target,
+      handedness: e.detail.component.data.hand
+    };
+
+    if (data.handedness === this.handedness) {
+      this.controllerQueue.unshift(data);
+    } else {
+      this.controllerQueue.push(data);
+    }
+
+    this.updateController();
+  },
+
+  handleControllerDisconnected: function(e) {
+    for (let i = 0; i < this.controllerQueue.length; i++) {
+      if (e.target === this.controllerQueue[i].controller) {
+        this.controllerQueue.splice(i, 1);
+        this.updateController();
+        return;
+      }
+    }
+  },
+
+  updateController: function() {
+    this.hasPointingDevice = this.controllerQueue.length > 0 && this.inVR;
+    this.cursor.el.setAttribute("cursor-controller", "drawLine", this.hasPointingDevice);
+
+    this.cursor.setCursorVisibility(true);
+
+    if (this.hasPointingDevice) {
+      const controllerData = this.controllerQueue[0];
+      const hand = controllerData.handedness;
+      this.controller = controllerData.controller;
+      this.cursor.el.setAttribute("cursor-controller", {
+        rayObject: hand === "left" ? this.data.leftControllerRayObject : this.data.rightControllerRayObject
+      });
+    } else {
+      this.controller = null;
+      this.cursor.el.setAttribute("cursor-controller", { rayObject: this.data.gazeCursorRayObject });
+    }
+
+    if (this.actionEventHandler) {
+      this.actionEventHandler.setHandThatAlsoDrivesCursor(this.controller);
+    }
+  }
+});
diff --git a/src/components/look-on-mobile.js b/src/components/look-on-mobile.js
index 7c99a261c5886f3652ca4b6c1655e2ad1057b0e5..768df1acdf3073ba65192abc0e5250d9b8e58769 100644
--- a/src/components/look-on-mobile.js
+++ b/src/components/look-on-mobile.js
@@ -1,5 +1,4 @@
 const PolyfillControls = AFRAME.utils.device.PolyfillControls;
-const PI_4 = Math.PI / 4;
 const TWOPI = Math.PI * 2;
 
 const abs = Math.abs;
@@ -29,9 +28,9 @@ const average = a => {
 
 AFRAME.registerComponent("look-on-mobile", {
   schema: {
-    enabled: { default: false },
     horizontalLookSpeedRatio: { default: 0.4 }, // motion applied to camera / motion of polyfill object
-    verticalLookSpeedRatio: { default: 0.4 } // motion applied to camera / motion of polyfill object
+    verticalLookSpeedRatio: { default: 0.4 }, // motion applied to camera / motion of polyfill object
+    camera: { type: "selector" }
   },
 
   init() {
@@ -49,35 +48,29 @@ AFRAME.registerComponent("look-on-mobile", {
     this.polyfillObject = new THREE.Object3D();
     this.polyfillControls = new PolyfillControls(this.polyfillObject);
   },
+
   pause() {
     this.el.removeEventListener("rotateX", this.onRotateX);
     this.polyfillControls = null;
     this.polyfillObject = null;
   },
-  onRotateX(e) {
-    this.pendingLookX = e.detail.value * 0.8;
+
+  update() {
+    this.cameraController = this.data.camera.components["pitch-yaw-rotator"];
   },
 
-  registerLookControls(lookControls) {
-    this.lookControls = lookControls;
-    this.lookControls.data.enabled = false;
-    this.lookControls.polyfillControls.update = () => {};
+  onRotateX(e) {
+    this.pendingLookX = e.detail.value;
   },
 
-  tick(t, dt) {
-    if (!this.data.enabled) return;
-    const scene = this.el.sceneEl;
+  tick() {
     const hmdEuler = this.hmdEuler;
-    const pitchObject = this.lookControls.pitchObject;
-    const yawObject = this.lookControls.yawObject;
-    const joystick = this.pendingLookX * dt / 1000;
     const { horizontalLookSpeedRatio, verticalLookSpeedRatio } = this.data;
-    if (scene.is("vr-mode") && scene.checkHeadsetConnected()) return;
     this.polyfillControls.update();
     hmdEuler.setFromQuaternion(this.polyfillObject.quaternion, "YXZ");
 
-    const dX = difference(hmdEuler.x, this.prevX);
-    const dY = difference(hmdEuler.y, this.prevY);
+    const dX = THREE.Math.RAD2DEG * difference(hmdEuler.x, this.prevX);
+    const dY = THREE.Math.RAD2DEG * difference(hmdEuler.y, this.prevY);
 
     this.dXBuffer.push(Math.abs(dX) < 0.001 ? 0 : dX);
     this.dYBuffer.push(Math.abs(dY) < 0.001 ? 0 : dY);
@@ -89,11 +82,11 @@ AFRAME.registerComponent("look-on-mobile", {
       this.dYBuffer.splice(0, 1);
     }
 
-    yawObject.rotation.y += average(this.dYBuffer) * horizontalLookSpeedRatio;
-    pitchObject.rotation.x += average(this.dXBuffer) * verticalLookSpeedRatio + joystick;
-    pitchObject.rotation.x = Math.max(-PI_4, Math.min(PI_4, pitchObject.rotation.x));
+    const deltaYaw = average(this.dYBuffer) * horizontalLookSpeedRatio;
+    const deltaPitch = average(this.dXBuffer) * verticalLookSpeedRatio + this.pendingLookX;
+
+    this.cameraController.look(deltaPitch, deltaYaw);
 
-    this.lookControls.updateOrientation();
     this.prevX = hmdEuler.x;
     this.prevY = hmdEuler.y;
     this.pendingLookX = 0;
diff --git a/src/components/pitch-yaw-rotator.js b/src/components/pitch-yaw-rotator.js
new file mode 100644
index 0000000000000000000000000000000000000000..87f4e646959ead44cec4b9223a52535081244240
--- /dev/null
+++ b/src/components/pitch-yaw-rotator.js
@@ -0,0 +1,30 @@
+const degToRad = THREE.Math.degToRad;
+AFRAME.registerComponent("pitch-yaw-rotator", {
+  schema: {
+    minPitch: { default: -50 },
+    maxPitch: { default: 50 }
+  },
+
+  init() {
+    this.pitch = 0;
+    this.yaw = 0;
+    this.rotation = { x: 0, y: 0, z: 0 };
+  },
+
+  look(deltaPitch, deltaYaw) {
+    const { minPitch, maxPitch } = this.data;
+    this.pitch += deltaPitch;
+    this.pitch = Math.max(minPitch, Math.min(maxPitch, this.pitch));
+    this.yaw += deltaYaw;
+  },
+
+  tick() {
+    this.rotation.x = this.pitch;
+    this.rotation.y = this.yaw;
+
+    // Update rotation of object3D the same way the rotation component of aframe does,
+    // skipping the work that would be done if we used this.el.setAttribute("rotation", this.rotation);
+    this.el.object3D.rotation.set(degToRad(this.rotation.x), degToRad(this.rotation.y), degToRad(this.rotation.z));
+    this.el.object3D.rotation.order = "YXZ";
+  }
+});
diff --git a/src/components/super-spawner.js b/src/components/super-spawner.js
index 083f81db766a810876b563a77b8de18544daf912..08491ec0ea05f253c720b50e078c5895bd3f188f 100644
--- a/src/components/super-spawner.js
+++ b/src/components/super-spawner.js
@@ -10,7 +10,7 @@ AFRAME.registerComponent("super-spawner", {
     spawnPosition: { type: "vec3" },
     useCustomSpawnRotation: { default: false },
     spawnRotation: { type: "vec4" },
-    events: { default: ["cursor-grab", "action_grab"] },
+    events: { default: ["cursor-grab", "hand_grab"] },
     spawnCooldown: { default: 1 }
   },
 
diff --git a/src/hub.html b/src/hub.html
index 9abac20147fc7731b96ec018b972449d21d8cd6b..24f7b5e8009fb064de253eaa0e9b7a71126b3674 100644
--- a/src/hub.html
+++ b/src/hub.html
@@ -42,7 +42,16 @@
         personal-space-bubble="debug: false;"
         vr-mode-ui="enabled: false"
         pinch-to-move
-        look-on-mobile
+        input-configurator="
+                  gazeCursorRayObject: #player-camera-reverse-z;
+                  cursorController: #cursor-controller;
+                  gazeTeleporter: #gaze-teleport;
+                  camera: #player-camera;
+                  playerRig: #player-rig;
+                  leftController: #player-left-controller;
+                  leftControllerRayObject: #player-left-controller-reverse-z;
+                  rightController: #player-right-controller;
+                  rightControllerRayObject: #player-right-controller-reverse-z;"
     >
 
         <a-assets>
@@ -184,13 +193,13 @@
                 ></a-entity>
             </template>
 
-            <a-mixin id="super-hands"
+            <a-mixin id="controller-super-hands"
                 super-hands="
                     colliderEvent: collisions; colliderEventProperty: els;
                     colliderEndEvent: collisions; colliderEndEventProperty: clearedEls;
-                    grabStartButtons: action_grab; grabEndButtons: action_release;
-                    stretchStartButtons: action_grab; stretchEndButtons: action_release;
-                    dragDropStartButtons: action_grab; dragDropEndButtons: action_release;"
+                    grabStartButtons: hand_grab; grabEndButtons: hand_release;
+                    stretchStartButtons: hand_grab; stretchEndButtons: hand_release;
+                    dragDropStartButtons: hand_grab; dragDropEndButtons: hand_release;"
                 collision-filter="collisionForces: false"
                 physics-collider
             ></a-mixin>
@@ -203,10 +212,7 @@
             id="cursor-controller"
             cursor-controller="
                 cursor: #cursor;
-                camera: #player-camera;
-                playerRig: #player-rig;
-                physicalHandSelector: #player-right-controller;
-                gazeTeleportControls: #gaze-teleport;"
+                camera: #player-camera; "
             raycaster="objects: .collidable, .interactable, .ui; far: 3;"
             line="visible: false; color: white; opacity: 0.2;"
         ></a-entity>
@@ -263,6 +269,7 @@
                 camera
                 position="0 1.6 0"
                 personal-space-bubble="radius: 0.4"
+                pitch-yaw-rotator
             >
                 <a-entity
                     id="gaze-teleport"
@@ -270,13 +277,14 @@
                     teleport-controls="
                     cameraRig: #player-rig;
                     teleportOrigin: #player-camera;
-                    button: cursor-teleport_;
+                    button: gaze-teleport_;
                     collisionEntities: [nav-mesh];
                     drawIncrementally: true;
                     incrementalDrawMs: 600;
                     hitOpacity: 0.3;
                     missOpacity: 0.2;"
                 ></a-entity>
+                <a-entity id="player-camera-reverse-z" rotation="0 180 0"></a-entity>
             </a-entity>
 
             <a-entity
@@ -295,9 +303,10 @@
                     missOpacity: 0.2;"
                 haptic-feedback
                 body="type: static; shape: none;"
-                mixin="super-hands"
+                mixin="controller-super-hands"
                 controls-shape-offset
             >
+                <a-entity id="player-left-controller-reverse-z" rotation="0 180 0"></a-entity>
             </a-entity>
 
             <a-entity
@@ -316,9 +325,11 @@
                     missOpacity: 0.2;"
                 haptic-feedback
                 body="type: static; shape: none;"
-                mixin="super-hands"
+                mixin="controller-super-hands"
                 controls-shape-offset
-            ></a-entity>
+            >
+                <a-entity id="player-right-controller-reverse-z" rotation="0 180 0"></a-entity>
+            </a-entity>
 
             <a-entity gltf-model-plus="inflate: true;"
                       class="model">
diff --git a/src/hub.js b/src/hub.js
index 410febf04af8acf4825126bc657ab6d14bb461b9..76b202b3d3fa535334d699fe44d8e62e5ba80f44 100644
--- a/src/hub.js
+++ b/src/hub.js
@@ -64,6 +64,8 @@ import "./components/scene-shadow";
 import "./components/avatar-replay";
 import "./components/pinch-to-move";
 import "./components/look-on-mobile";
+import "./components/pitch-yaw-rotator";
+import "./components/input-configurator";
 
 import ReactDOM from "react-dom";
 import React from "react";
@@ -123,10 +125,6 @@ import registerTelemetry from "./telemetry";
 
 import { getAvailableVREntryTypes, VR_DEVICE_AVAILABILITY } from "./utils/vr-caps-detect.js";
 import ConcurrentLoadDetector from "./utils/concurrent-load-detector.js";
-import TouchEventsHandler from "./utils/touch-events-handler.js";
-import MouseEventsHandler from "./utils/mouse-events-handler.js";
-window.APP.touchEventsHandler = new TouchEventsHandler();
-window.APP.mouseEventsHandler = new MouseEventsHandler();
 
 function qsTruthy(param) {
   const val = qs[param];
@@ -230,6 +228,7 @@ const onReady = async () => {
 
   const enterScene = async (mediaStream, enterInVR, hubId) => {
     const scene = document.querySelector("a-scene");
+    scene.style.cursor = "none";
     scene.renderer.sortObjects = true;
     const playerRig = document.querySelector("#player-rig");
     document.querySelector("canvas").classList.remove("blurred");
@@ -241,24 +240,6 @@ const onReady = async () => {
 
     AFRAME.registerInputActions(inGameActions, "default");
 
-    const camera = document.querySelector("#player-camera");
-    const registerLookControls = e => {
-      if (e.detail.name !== "look-controls") return;
-      camera.removeEventListener("componentinitialized", registerLookControls);
-
-      window.APP.touchEventsHandler.registerLookControls(camera.components["look-controls"]);
-      scene.components["look-on-mobile"].registerLookControls(camera.components["look-controls"]);
-      scene.setAttribute("look-on-mobile", "enabled", true);
-
-      window.APP.mouseEventsHandler.registerLookControls(camera.components["look-controls"]);
-      window.APP.mouseEventsHandler.setInverseMouseLook(qsTruthy("invertMouseLook"));
-    };
-    camera.addEventListener("componentinitialized", registerLookControls);
-    camera.setAttribute("look-controls", {
-      touchEnabled: false,
-      hmdEnabled: false
-    });
-
     scene.setAttribute("networked-scene", {
       room: hubId,
       serverURL: process.env.JANUS_SERVER
diff --git a/src/input-mappings.js b/src/input-mappings.js
index a1c4c06a2d7cf1f8442b81fee5cfa25821af90b5..ecef8335bdd9a4223c93f7a22deb5f4549c0f384 100644
--- a/src/input-mappings.js
+++ b/src/input-mappings.js
@@ -83,7 +83,7 @@ const config = {
         thumbsticktouchend: "thumb_up",
         triggerdown: ["action_grab", "index_down"],
         triggerup: ["action_release", "index_up"],
-        "axismove.reverseY": { left: "move" },
+        "axismove.reverseY": { left: "move", right: "move_duck" },
         abuttondown: "action_primary_down",
         abuttonup: "action_primary_up"
       },
@@ -107,7 +107,7 @@ const config = {
         trackpadtouchend: "thumb_up",
         triggerdown: ["action_grab", "index_down"],
         triggerup: ["action_release", "index_up"],
-        axisMoveWithDeadzone: { left: "move" }
+        axisMoveWithDeadzone: { left: "move", right: "move_duck" }
       },
       "daydream-controls": {
         trackpad_dpad4_pressed_west_down: "snap_rotate_left",
diff --git a/src/utils/action-event-handler.js b/src/utils/action-event-handler.js
new file mode 100644
index 0000000000000000000000000000000000000000..1884a7313bf88bc345eff106856c270403d2a74f
--- /dev/null
+++ b/src/utils/action-event-handler.js
@@ -0,0 +1,161 @@
+export default class ActionEventHandler {
+  constructor(scene, cursor) {
+    this.scene = scene;
+    this.cursor = cursor;
+    this.isCursorInteracting = false;
+    this.isTeleporting = false;
+    this.handThatAlsoDrivesCursor = null;
+    this.hovered = false;
+
+    this.addEventListeners = this.addEventListeners.bind(this);
+    this.tearDown = this.tearDown.bind(this);
+    this.onPrimaryDown = this.onPrimaryDown.bind(this);
+    this.onPrimaryUp = this.onPrimaryUp.bind(this);
+    this.onGrab = this.onGrab.bind(this);
+    this.onRelease = this.onRelease.bind(this);
+    this.onCardboardButtonDown = this.onCardboardButtonDown.bind(this);
+    this.onCardboardButtonUp = this.onCardboardButtonUp.bind(this);
+    this.onMoveDuck = this.onMoveDuck.bind(this);
+    this.manageCursorEnabled = this.manageCursorEnabled.bind(this);
+    this.addEventListeners();
+  }
+
+  addEventListeners() {
+    this.scene.addEventListener("action_primary_down", this.onPrimaryDown);
+    this.scene.addEventListener("action_primary_up", this.onPrimaryUp);
+    this.scene.addEventListener("action_grab", this.onGrab);
+    this.scene.addEventListener("action_release", this.onRelease);
+    this.scene.addEventListener("move_duck", this.onMoveDuck);
+    this.scene.addEventListener("cardboardbuttondown", this.onCardboardButtonDown); // TODO: These should be actions
+    this.scene.addEventListener("cardboardbuttonup", this.onCardboardButtonUp);
+  }
+
+  tearDown() {
+    this.scene.removeEventListener("action_primary_down", this.onPrimaryDown);
+    this.scene.removeEventListener("action_primary_up", this.onPrimaryUp);
+    this.scene.removeEventListener("action_grab", this.onGrab);
+    this.scene.removeEventListener("action_release", this.onRelease);
+    this.scene.removeEventListener("move_duck", this.onMoveDuck);
+    this.scene.removeEventListener("cardboardbuttondown", this.onCardboardButtonDown);
+    this.scene.removeEventListener("cardboardbuttonup", this.onCardboardButtonUp);
+  }
+
+  onMoveDuck(e) {
+    this.cursor.changeDistanceMod(-e.detail.axis[1] / 8);
+  }
+
+  setHandThatAlsoDrivesCursor(handThatAlsoDrivesCursor) {
+    this.handThatAlsoDrivesCursor = handThatAlsoDrivesCursor;
+  }
+
+  onGrab(e) {
+    if (this.handThatAlsoDrivesCursor && this.handThatAlsoDrivesCursor === e.target) {
+      if (this.isCursorInteracting) {
+        return;
+      } else if (e.target.components["super-hands"].state.has("hover-start")) {
+        e.target.emit("hand_grab");
+        return;
+      } else {
+        this.isCursorInteracting = this.cursor.startInteraction();
+        return;
+      }
+    } else {
+      e.target.emit("hand_grab");
+      return;
+    }
+  }
+
+  onRelease(e) {
+    if (this.isCursorInteracting && this.handThatAlsoDrivesCursor && this.handThatAlsoDrivesCursor === e.target) {
+      this.isCursorInteracting = false;
+      this.cursor.endInteraction();
+    } else {
+      e.target.emit("hand_release");
+    }
+  }
+
+  onPrimaryDown(e) {
+    if (this.handThatAlsoDrivesCursor && this.handThatAlsoDrivesCursor === e.target) {
+      if (this.isCursorInteracting) {
+        return;
+      } else if (e.target.components["super-hands"].state.has("hover-start")) {
+        e.target.emit("hand_grab");
+        return;
+      } else {
+        this.isCursorInteracting = this.cursor.startInteraction();
+        if (this.isCursorInteracting) return;
+      }
+    }
+
+    this.cursor.setCursorVisibility(false);
+    const button = e.target.components["teleport-controls"].data.button;
+    e.target.emit(button + "down");
+    this.isTeleporting = true;
+  }
+
+  onPrimaryUp(e) {
+    const isCursorHand = this.handThatAlsoDrivesCursor && this.handThatAlsoDrivesCursor === e.target;
+    if (this.isCursorInteracting && isCursorHand) {
+      this.isCursorInteracting = false;
+      this.cursor.endInteraction();
+      return;
+    }
+
+    const state = e.target.components["super-hands"].state;
+    if (state.has("grab-start")) {
+      e.target.emit("hand_release");
+      return;
+    }
+
+    if (isCursorHand) {
+      this.cursor.setCursorVisibility(!state.has("hover-start"));
+    }
+    const button = e.target.components["teleport-controls"].data.button;
+    e.target.emit(button + "up");
+    this.isTeleporting = false;
+  }
+
+  onCardboardButtonDown(e) {
+    this.isCursorInteracting = this.cursor.startInteraction();
+    if (this.isCursorInteracting) {
+      return;
+    }
+
+    this.cursor.setCursorVisibility(false);
+
+    const gazeTeleport = e.target.querySelector("#gaze-teleport");
+    const button = gazeTeleport.components["teleport-controls"].data.button;
+    gazeTeleport.emit(button + "down");
+    this.isTeleporting = true;
+  }
+
+  onCardboardButtonUp(e) {
+    if (this.isCursorInteracting) {
+      this.isCursorInteracting = false;
+      this.cursor.endInteraction();
+      return;
+    }
+
+    this.cursor.setCursorVisibility(true);
+
+    const gazeTeleport = e.target.querySelector("#gaze-teleport");
+    const button = gazeTeleport.components["teleport-controls"].data.button;
+    gazeTeleport.emit(button + "up");
+    this.isTeleporting = false;
+  }
+
+  manageCursorEnabled() {
+    const handState = this.handThatAlsoDrivesCursor.components["super-hands"].state;
+    const handHoveredThisFrame = !this.hovered && handState.has("hover-start") && !this.isCursorInteracting;
+    const handStoppedHoveringThisFrame =
+      this.hovered === true && !handState.has("hover-start") && !handState.has("grab-start");
+    if (handHoveredThisFrame) {
+      this.hovered = true;
+      this.cursor.disable();
+    } else if (handStoppedHoveringThisFrame) {
+      this.hovered = false;
+      this.cursor.enable();
+      this.cursor.setCursorVisibility(!this.isTeleporting);
+    }
+  }
+}
diff --git a/src/utils/gearvr-mouse-events-handler.js b/src/utils/gearvr-mouse-events-handler.js
new file mode 100644
index 0000000000000000000000000000000000000000..90ee3c8538bf912592b89a6051c243b406a1039f
--- /dev/null
+++ b/src/utils/gearvr-mouse-events-handler.js
@@ -0,0 +1,52 @@
+export default class GearVRMouseEventsHandler {
+  constructor(cursor, gazeTeleporter) {
+    this.cursor = cursor;
+    this.gazeTeleporter = gazeTeleporter;
+    this.isMouseDownHandledByCursor = false;
+    this.isMouseDownHandledByGazeTeleporter = false;
+
+    this.addEventListeners = this.addEventListeners.bind(this);
+    this.tearDown = this.tearDown.bind(this);
+    this.onMouseDown = this.onMouseDown.bind(this);
+    this.onMouseUp = this.onMouseUp.bind(this);
+    this.addEventListeners();
+  }
+
+  addEventListeners() {
+    document.addEventListener("mousedown", this.onMouseDown);
+    document.addEventListener("mouseup", this.onMouseUp);
+  }
+
+  tearDown() {
+    document.removeEventListener("mousedown", this.onMouseDown);
+    document.removeEventListener("mouseup", this.onMouseUp);
+  }
+
+  onMouseDown() {
+    this.isMouseDownHandledByCursor = this.cursor.startInteraction();
+    if (this.isMouseDownHandledByCursor) {
+      return;
+    }
+
+    this.cursor.setCursorVisibility(false);
+
+    const button = this.gazeTeleporter.data.button;
+    this.gazeTeleporter.el.emit(button + "down");
+    this.isMouseDownHandledByGazeTeleporter = true;
+  }
+
+  onMouseUp() {
+    if (this.isMouseDownHandledByCursor) {
+      this.cursor.endInteraction();
+      this.isMouseDownHandledByCursor = false;
+    }
+
+    this.cursor.setCursorVisibility(true);
+
+    if (this.isMouseDownHandledByGazeTeleporter) {
+      const button = this.gazeTeleporter.data.button;
+      this.gazeTeleporter.el.emit(button + "up");
+      this.isMouseDownHandledByGazeTeleporter = false;
+    }
+  }
+}
diff --git a/src/utils/mouse-events-handler.js b/src/utils/mouse-events-handler.js
index 425b16d5262c62af8f5c710d1b7e4945520973fb..b034006a1fafa9756cc94928288622a7b022bb07 100644
--- a/src/utils/mouse-events-handler.js
+++ b/src/utils/mouse-events-handler.js
@@ -1,52 +1,38 @@
-const HORIZONTAL_LOOK_SPEED = 0.0035;
-const VERTICAL_LOOK_SPEED = 0.0021;
-const PI_4 = Math.PI / 4;
+// TODO: Make look speed adjustable by the user
+const HORIZONTAL_LOOK_SPEED = 0.1;
+const VERTICAL_LOOK_SPEED = 0.06;
 
 export default class MouseEventsHandler {
-  constructor() {
-    this.cursor = null;
-    this.lookControls = null;
-    this.isMouseDown = false;
-    this.isMouseDownHandledByCursor = false;
+  constructor(cursor, cameraController) {
+    this.cursor = cursor;
+    this.cameraController = cameraController;
+    this.isLeftButtonDown = false;
+    this.isLeftButtonHandledByCursor = false;
     this.isPointerLocked = false;
-    this.dXBuffer = [];
-    this.dYBuffer = [];
 
-    this.registerCursor = this.registerCursor.bind(this);
-    this.registerLookControls = this.registerLookControls.bind(this);
-    this.isReady = this.isReady.bind(this);
     this.addEventListeners = this.addEventListeners.bind(this);
     this.onMouseDown = this.onMouseDown.bind(this);
+    this.onLeftButtonDown = this.onLeftButtonDown.bind(this);
+    this.onRightButtonDown = this.onRightButtonDown.bind(this);
+    this.tearDown = this.tearDown.bind(this);
     this.onMouseMove = this.onMouseMove.bind(this);
     this.onMouseUp = this.onMouseUp.bind(this);
     this.onMouseWheel = this.onMouseWheel.bind(this);
     this.look = this.look.bind(this);
 
-    document.addEventListener("contextmenu", function(e) {
-      e.preventDefault();
-    });
-  }
-
-  setInverseMouseLook(invert) {
-    this.invertMouseLook = invert;
-  }
-
-  registerCursor(cursor) {
-    this.cursor = cursor;
-    if (this.isReady()) {
-      this.addEventListeners();
-    }
+    this.addEventListeners();
   }
 
-  registerLookControls(lookControls) {
-    this.lookControls = lookControls;
-    if (this.isReady()) {
-      this.addEventListeners();
-    }
+  tearDown() {
+    document.removeEventListener("mousedown", this.onMouseDown);
+    document.removeEventListener("mousemove", this.onMouseMove);
+    document.removeEventListener("mouseup", this.onMouseUp);
+    document.removeEventListener("wheel", this.onMouseWheel);
+    document.removeEventListener("contextmenu", this.onContextMenu);
   }
 
-  isReady() {
-    return this.cursor && this.lookControls;
+  setInverseMouseLook(invert) {
+    this.invertMouseLook = invert;
   }
 
   addEventListeners() {
@@ -54,61 +40,76 @@ export default class MouseEventsHandler {
     document.addEventListener("mousemove", this.onMouseMove);
     document.addEventListener("mouseup", this.onMouseUp);
     document.addEventListener("wheel", this.onMouseWheel);
+    document.addEventListener("contextmenu", this.onContextMenu);
+  }
+
+  onContextMenu(e) {
+    e.preventDefault();
   }
 
   onMouseDown(e) {
     const isLeftButton = e.button === 0;
+    const isRightButton = e.button === 2;
     if (isLeftButton) {
-      this.isMouseDownHandledByCursor = this.cursor.handleMouseDown();
-      this.isMouseDown = true;
+      this.onLeftButtonDown();
+    } else if (isRightButton) {
+      this.onRightButtonDown();
+    }
+  }
+
+  onLeftButtonDown() {
+    this.isLeftButtonDown = true;
+    this.isLeftButtonHandledByCursor = this.cursor.startInteraction();
+  }
+
+  onRightButtonDown() {
+    if (this.isPointerLocked) {
+      document.exitPointerLock();
+      this.isPointerLocked = false;
     } else {
-      if (this.isPointerLocked) {
-        document.exitPointerLock();
-        this.isPointerLocked = false;
-      } else {
-        document.body.requestPointerLock();
-        this.isPointerLocked = true;
-      }
+      document.body.requestPointerLock();
+      this.isPointerLocked = true;
     }
   }
 
   onMouseWheel(e) {
-    this.cursor.handleMouseWheel(e);
+    switch (e.deltaMode) {
+      case e.DOM_DELTA_PIXEL:
+        this.cursor.changeDistanceMod(e.deltaY / 500);
+        break;
+      case e.DOM_DELTA_LINE:
+        this.cursor.changeDistanceMod(e.deltaY / 10);
+        break;
+      case e.DOM_DELTA_PAGE:
+        this.cursor.changeDistanceMod(e.deltaY / 2);
+        break;
+    }
   }
 
   onMouseMove(e) {
-    const shouldLook = (this.isMouseDown && !this.isMouseDownHandledByCursor) || this.isPointerLocked;
+    const shouldLook = this.isPointerLocked || (this.isLeftButtonDown && !this.isLeftButtonHandledByCursor);
     if (shouldLook) {
       this.look(e);
     }
 
-    this.cursor.handleMouseMove(e);
-  }
-
-  look(e) {
-    const movementX = e.movementX;
-    const movementY = e.movementY;
-
-    const sign = this.invertMouseLook ? 1 : -1;
-    this.lookControls.yawObject.rotation.y += sign * movementX * HORIZONTAL_LOOK_SPEED;
-    this.lookControls.pitchObject.rotation.x += sign * movementY * VERTICAL_LOOK_SPEED;
-    this.lookControls.pitchObject.rotation.x = Math.max(
-      -PI_4,
-      Math.min(PI_4, this.lookControls.pitchObject.rotation.x)
-    );
-    this.lookControls.updateOrientation();
+    this.cursor.moveCursor(e.clientX / window.innerWidth * 2 - 1, -(e.clientY / window.innerHeight) * 2 + 1);
   }
 
   onMouseUp(e) {
     const isLeftButton = e.button === 0;
-    if (isLeftButton) {
-      if (this.isMouseDownHandledByCursor) {
-        this.cursor.handleMouseUp();
-      }
-      this.isMouseDownHandledByCursor = false;
-      this.isMouseDown = false;
-      this.dXBuffer = [];
-      this.dYBuffer = [];
+    if (!isLeftButton) return;
+
+    if (this.isLeftButtonHandledByCursor) {
+      this.cursor.endInteraction();
     }
+    this.isLeftButtonHandledByCursor = false;
+    this.isLeftButtonDown = false;
+  }
+
+  look(e) {
+    const sign = this.invertMouseLook ? 1 : -1;
+    const deltaPitch = e.movementY * VERTICAL_LOOK_SPEED * sign;
+    const deltaYaw = e.movementX * HORIZONTAL_LOOK_SPEED * sign;
+    this.cameraController.look(deltaPitch, deltaYaw);
   }
 }
diff --git a/src/utils/touch-events-handler.js b/src/utils/touch-events-handler.js
index b652dc9b8024274684a9f95fc9c9548a93420658..91b9bc25ba0e2ec11428219a3f248e7ec052dbfe 100644
--- a/src/utils/touch-events-handler.js
+++ b/src/utils/touch-events-handler.js
@@ -1,13 +1,12 @@
 const VIRTUAL_JOYSTICK_HEIGHT = 0.8;
-const HORIZONTAL_LOOK_SPEED = 0.006;
-const VERTICAL_LOOK_SPEED = 0.003;
-const PI_4 = Math.PI / 4;
+const HORIZONTAL_LOOK_SPEED = 0.35;
+const VERTICAL_LOOK_SPEED = 0.18;
 
 export default class TouchEventsHandler {
-  constructor() {
-    this.cursor = null;
-    this.lookControls = null;
-    this.pinchEmitter = null;
+  constructor(cursor, cameraController, pinchEmitter) {
+    this.cursor = cursor;
+    this.cameraController = cameraController;
+    this.pinchEmitter = pinchEmitter;
     this.touches = [];
     this.touchReservedForCursor = null;
     this.touchesReservedForPinch = [];
@@ -16,9 +15,6 @@ export default class TouchEventsHandler {
     this.pinchTouchId1 = -1;
     this.pinchTouchId2 = -1;
 
-    this.registerCursor = this.registerCursor.bind(this);
-    this.registerLookControls = this.registerLookControls.bind(this);
-    this.isReady = this.isReady.bind(this);
     this.addEventListeners = this.addEventListeners.bind(this);
     this.handleTouchStart = this.handleTouchStart.bind(this);
     this.singleTouchStart = this.singleTouchStart.bind(this);
@@ -28,31 +24,9 @@ export default class TouchEventsHandler {
     this.singleTouchEnd = this.singleTouchEnd.bind(this);
     this.pinch = this.pinch.bind(this);
     this.look = this.look.bind(this);
-  }
-
-  registerCursor(cursor) {
-    this.cursor = cursor;
-    if (this.isReady()) {
-      this.addEventListeners();
-    }
-  }
-
-  registerLookControls(lookControls) {
-    this.lookControls = lookControls;
-    if (this.isReady()) {
-      this.addEventListeners();
-    }
-  }
-
-  registerPinchEmitter(pinchEmitter) {
-    this.pinchEmitter = pinchEmitter;
-    if (this.isReady()) {
-      this.addEventListeners();
-    }
-  }
+    this.tearDown = this.tearDown.bind(this);
 
-  isReady() {
-    return this.cursor && this.lookControls && this.pinchEmitter;
+    this.addEventListeners();
   }
 
   addEventListeners() {
@@ -62,7 +36,16 @@ export default class TouchEventsHandler {
     document.addEventListener("touchcancel", this.handleTouchEnd);
   }
 
+  tearDown() {
+    document.removeEventListener("touchstart", this.handleTouchStart);
+    document.removeEventListener("touchmove", this.handleTouchMove);
+    document.removeEventListener("touchend", this.handleTouchEnd);
+    document.removeEventListener("touchcancel", this.handleTouchEnd);
+  }
+
   handleTouchStart(e) {
+    this.cursor.setCursorVisibility(false);
+
     Array.prototype.forEach.call(e.changedTouches, this.singleTouchStart);
   }
 
@@ -70,8 +53,12 @@ export default class TouchEventsHandler {
     if (touch.clientY / window.innerHeight >= VIRTUAL_JOYSTICK_HEIGHT) {
       return;
     }
-    if (!this.touchReservedForCursor && this.cursor.handleTouchStart(touch)) {
-      this.touchReservedForCursor = touch;
+    if (!this.touchReservedForCursor) {
+      this.cursor.moveCursor(touch.clientX / window.innerWidth * 2 - 1, -(touch.clientY / window.innerHeight) * 2 + 1);
+      this.cursor.forceCursorUpdate();
+      if (this.cursor.startInteraction()) {
+        this.touchReservedForCursor = touch;
+      }
     }
     this.touches.push(touch);
   }
@@ -86,7 +73,7 @@ export default class TouchEventsHandler {
 
   singleTouchMove(touch) {
     if (this.touchReservedForCursor && touch.identifier === this.touchReservedForCursor.identifier) {
-      this.cursor.handleTouchMove(touch);
+      this.cursor.moveCursor(touch.clientX / window.innerWidth * 2 - 1, -(touch.clientY / window.innerHeight) * 2 + 1);
       return;
     }
     if (touch.clientY / window.innerHeight >= VIRTUAL_JOYSTICK_HEIGHT) return;
@@ -114,7 +101,10 @@ export default class TouchEventsHandler {
     }
     if (touch.identifier === this.touchReservedForLookControls.identifier) {
       if (!this.touchReservedForCursor) {
-        this.cursor.handleTouchMove(touch);
+        this.cursor.moveCursor(
+          touch.clientX / window.innerWidth * 2 - 1,
+          -(touch.clientY / window.innerHeight) * 2 + 1
+        );
       }
       this.look(this.touchReservedForLookControls, touch);
       this.touchReservedForLookControls = touch;
@@ -133,15 +123,9 @@ export default class TouchEventsHandler {
   }
 
   look(prevTouch, touch) {
-    const dX = touch.clientX - prevTouch.clientX;
-    const dY = touch.clientY - prevTouch.clientY;
-
-    this.lookControls.yawObject.rotation.y += dX * HORIZONTAL_LOOK_SPEED;
-    this.lookControls.pitchObject.rotation.x += dY * VERTICAL_LOOK_SPEED;
-    this.lookControls.pitchObject.rotation.x = Math.max(
-      -PI_4,
-      Math.min(PI_4, this.lookControls.pitchObject.rotation.x)
-    );
+    const deltaPitch = (touch.clientY - prevTouch.clientY) * VERTICAL_LOOK_SPEED;
+    const deltaYaw = (touch.clientX - prevTouch.clientX) * HORIZONTAL_LOOK_SPEED;
+    this.cameraController.look(deltaPitch, deltaYaw);
   }
 
   handleTouchEnd(e) {
@@ -156,7 +140,7 @@ export default class TouchEventsHandler {
     this.touches.splice(touchIndex, 1);
 
     if (this.touchReservedForCursor && touch.identifier === this.touchReservedForCursor.identifier) {
-      this.cursor.handleTouchEnd(touch);
+      this.cursor.endInteraction(touch);
       this.touchReservedForCursor = null;
       return;
     }