diff --git a/src/components/cursor-controller.js b/src/components/cursor-controller.js
index bbde2b5d87804807b460df1b8d132c290c7ee978..cec1db7279cff269a6bbc4843f26888ea0978389 100644
--- a/src/components/cursor-controller.js
+++ b/src/components/cursor-controller.js
@@ -39,14 +39,14 @@ AFRAME.registerComponent("cursor-controller", {
     this.origin = new THREE.Vector3();
     this.direction = new THREE.Vector3();
     this.controllerQuaternion = new THREE.Quaternion();
-    this.activeTouch = null;
 
     this.data.cursor.setAttribute("material", { color: this.data.cursorColorUnhovered });
 
-    this._handleTouchStart = this._handleTouchStart.bind(this);
-    this._handleSingleTouchStart = this._handleSingleTouchStart.bind(this);
-    this._handleTouchMove = this._handleTouchMove.bind(this);
-    this._handleTouchEnd = this._handleTouchEnd.bind(this);
+    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._handleMouseDown = this._handleMouseDown.bind(this);
     this._handleMouseMove = this._handleMouseMove.bind(this);
     this._handleMouseUp = this._handleMouseUp.bind(this);
@@ -78,10 +78,6 @@ AFRAME.registerComponent("cursor-controller", {
   },
 
   play: function() {
-    document.addEventListener("touchstart", this._handleTouchStart);
-    document.addEventListener("touchmove", this._handleTouchMove);
-    document.addEventListener("touchend", this._handleTouchEnd);
-    document.addEventListener("touchcancel", this._handleTouchEnd);
     document.addEventListener("mousedown", this._handleMouseDown);
     document.addEventListener("mousemove", this._handleMouseMove);
     document.addEventListener("mouseup", this._handleMouseUp);
@@ -103,10 +99,6 @@ AFRAME.registerComponent("cursor-controller", {
   },
 
   pause: function() {
-    document.removeEventListener("touchstart", this._handleTouchStart);
-    document.removeEventListener("touchmove", this._handleTouchMove);
-    document.removeEventListener("touchend", this._handleTouchEnd);
-    document.removeEventListener("touchcancel", this._handleTouchEnd);
     document.removeEventListener("mousedown", this._handleMouseDown);
     document.removeEventListener("mousemove", this._handleMouseMove);
     document.removeEventListener("mouseup", this._handleMouseUp);
@@ -231,8 +223,12 @@ AFRAME.registerComponent("cursor-controller", {
   },
 
   _setLookControlsEnabled(enabled) {
-    if (window.LookControlsToggle) {
-      window.LookControlsToggle.toggle(enabled, this);
+    const lookControls = this.data.camera.components["look-controls"];
+    if (!lookControls) return;
+    if (enabled) {
+      lookControls.play();
+    } else {
+      lookControls.pause();
     }
   },
 
@@ -254,19 +250,8 @@ AFRAME.registerComponent("cursor-controller", {
     this._setCursorVisibility(true);
   },
 
-  _handleTouchStart: function(e) {
-    if (!this.isMobile || this.hasPointingDevice) {
-      return;
-    }
-
-    for (let i = 0; i < e.touches.length; i++) {
-      this._handleSingleTouchStart(e.touches[i]);
-    }
-  },
-
-  _handleSingleTouchStart: function(touch) {
-    if (this.activeTouch || touch.clientY / window.innerHeight >= virtualJoystickCutoff) return;
-
+  handleTouchStart: function(touch) {
+    if (!this.isMobile || this.hasPointingDevice) return;
     // Update the ray and cursor positions
     const raycasterComp = this.el.components.raycaster;
     const raycaster = raycasterComp.raycaster;
@@ -280,42 +265,24 @@ AFRAME.registerComponent("cursor-controller", {
     if (intersections.length === 0 || intersections[0].distance >= this.data.maxDistance) {
       return;
     }
-    this.activeTouch = touch;
     cursor.object3D.position.copy(intersections[0].point);
     // Cursor position must be synced to physics before constraint is created
     cursor.components["static-body"].syncToPhysics();
-    this.activeTouch.isUsedByCursor = true;
-    cursor.emit("touch-used-by-cursor", this.activeTouch);
     cursor.emit("cursor-grab", {});
+    return true;
   },
 
-  _handleTouchMove: function(e) {
+  handleTouchMove: function(touch) {
     if (!this.isMobile || this.hasPointingDevice) return;
-
-    for (let i = 0; i < e.touches.length; i++) {
-      const touch = e.touches[i];
-      if (
-        (!this.activeTouch && touch.clientY / window.innerHeight < virtualJoystickCutoff) ||
-        (this.activeTouch && touch.identifier === this.activeTouch.identifier)
-      ) {
-        this.mousePos.set(touch.clientX / window.innerWidth * 2 - 1, -(touch.clientY / window.innerHeight) * 2 + 1);
-        return;
-      }
-    }
+    this.mousePos.set(touch.clientX / window.innerWidth * 2 - 1, -(touch.clientY / window.innerHeight) * 2 + 1);
   },
 
-  _handleTouchEnd: function(e) {
-    if (
-      !this.isMobile ||
-      this.hasPointingDevice ||
-      !this.activeTouch ||
-      Array.prototype.some.call(e.touches, touch => touch.identifier === this.activeTouch.identifier)
-    ) {
-      return;
-    }
+  handleTouchEnd: function(touch) {
+    // TODO: Should we emit cursor-release just in case
+    // hasPointingDevice changed just before this function call?
+    if (!this.isMobile || this.hasPointingDevice) return;
 
     this.data.cursor.emit("cursor-release", {});
-    this.activeTouch = null;
   },
 
   _handleMouseDown: function() {
diff --git a/src/components/pinch-to-move.js b/src/components/pinch-to-move.js
new file mode 100644
index 0000000000000000000000000000000000000000..9b8e1c1d446258d56d7c7c3fdce6551a37aaab7e
--- /dev/null
+++ b/src/components/pinch-to-move.js
@@ -0,0 +1,36 @@
+AFRAME.registerComponent("pinch-to-move", {
+  schema: {
+    speed: { default: 0.35 }
+  },
+  init() {
+    this.onPinch = this.onPinch.bind(this);
+    this.axis = [0, 0];
+    this.pinch = 0;
+    this.prevPinch = 0;
+    this.needsMove = false;
+  },
+  play() {
+    this.el.addEventListener("pinch", this.onPinch);
+  },
+  pause() {
+    this.el.removeEventListener("pinch", this.onPinch);
+  },
+  tick() {
+    if (this.needsMove) {
+      const diff = this.pinch - this.prevPinch;
+      this.axis[1] = diff * this.data.speed;
+      this.el.emit("move", { axis: this.axis });
+      this.prevPinch = this.pinch;
+    }
+    this.needsMove = false;
+  },
+  onPinch(e) {
+    const { isNewPinch, distance } = e.detail;
+    if (isNewPinch) {
+      this.prevPinch = distance;
+      return;
+    }
+    this.pinch = distance;
+    this.needsMove = true;
+  }
+});
diff --git a/src/hub.html b/src/hub.html
index 62d4984389c245fcbba362f07292d16b23864bd1..861ef092edbddebca3e4ce9335ebed7c478f91a6 100644
--- a/src/hub.html
+++ b/src/hub.html
@@ -37,6 +37,7 @@
         freeze-controller="toggleEvent: action_freeze"
         personal-space-bubble="debug: false;"
         vr-mode-ui="enabled: false"
+        pinch-to-move
     >
 
         <a-assets>
diff --git a/src/hub.js b/src/hub.js
index 35258cd35f7d907bdeb1fd8c59d90f72334d2449..5adf975702ed139b6ec5a2095c2373ae413d0e56 100644
--- a/src/hub.js
+++ b/src/hub.js
@@ -63,6 +63,7 @@ import "./components/networked-avatar";
 import "./components/css-class";
 import "./components/scene-shadow";
 import "./components/avatar-replay";
+import "./components/pinch-to-move";
 
 import ReactDOM from "react-dom";
 import React from "react";
@@ -120,10 +121,8 @@ import registerTelemetry from "./telemetry";
 import { generateDefaultProfile, generateRandomName } from "./utils/identity.js";
 import { getAvailableVREntryTypes, VR_DEVICE_AVAILABILITY } from "./utils/vr-caps-detect.js";
 import ConcurrentLoadDetector from "./utils/concurrent-load-detector.js";
-import Pinch from "./utils/pinch.js";
-import PinchToMove from "./utils/pinch-to-move.js";
-import LookControlsToggle from "./utils/look-controls-toggle.js";
-import PointerLookControls from "./utils/pointer-look-controls.js";
+import TouchEventsHandler from "./utils/touch-events-handler.js";
+window.APP.touchEventsHandler = new TouchEventsHandler();
 
 function qsTruthy(param) {
   const val = qs[param];
@@ -229,8 +228,6 @@ const onReady = async () => {
   const enterScene = async (mediaStream, enterInVR, hubId) => {
     const scene = document.querySelector("a-scene");
     scene.renderer.sortObjects = true;
-    const pinch = new Pinch(scene);
-    const pinchToMove = new PinchToMove(scene);
     const playerRig = document.querySelector("#player-rig");
     document.querySelector("canvas").classList.remove("blurred");
     scene.render();
@@ -242,9 +239,13 @@ const onReady = async () => {
     AFRAME.registerInputActions(inGameActions, "default");
 
     const camera = document.querySelector("#player-camera");
+    const registerLookControls = e => {
+      if (e.detail.name !== "look-controls") return;
+      window.APP.touchEventsHandler.registerLookControls(camera.components["look-controls"]);
+      camera.removeEventListener("commponentinitialized", registerLookControls);
+    };
+    camera.addEventListener("componentinitialized", registerLookControls);
     camera.setAttribute("look-controls", "touchEnabled", false);
-    window.PointerLookControls = new PointerLookControls(camera);
-    window.LookControlsToggle = new LookControlsToggle(camera, window.PointerLookControls);
 
     scene.setAttribute("networked-scene", {
       room: hubId,
diff --git a/src/utils/look-controls-toggle.js b/src/utils/look-controls-toggle.js
deleted file mode 100644
index 0a80ef87eec4f4a1d70b880a803330842b5a119b..0000000000000000000000000000000000000000
--- a/src/utils/look-controls-toggle.js
+++ /dev/null
@@ -1,35 +0,0 @@
-export default class LookControlsToggle {
-  constructor(lookControlsEl, pointerLookControls) {
-    this.lookControlsEl = lookControlsEl;
-    this.pointerLookControls = pointerLookControls;
-    this.toggle = this.toggle.bind(this);
-    this.allAgreeToEnable = this.allAgreeToEnable.bind(this);
-    this.requesters = {};
-  }
-
-  allAgreeToEnable() {
-    for (const i in this.requesters) {
-      if (!this.requesters[i]) {
-        return false;
-      }
-    }
-    return true;
-  }
-
-  toggle(enable, requester) {
-    this.requesters[requester] = enable;
-    const consensus = this.allAgreeToEnable();
-
-    if (!this.lookControls) {
-      this.lookControls = this.lookControlsEl.components["look-controls"];
-    }
-
-    if (consensus) {
-      this.lookControls.play();
-      this.pointerLookControls.start();
-    } else {
-      this.lookControls.pause();
-      this.pointerLookControls.stop();
-    }
-  }
-}
diff --git a/src/utils/pinch-to-move.js b/src/utils/pinch-to-move.js
deleted file mode 100644
index af474d5c43bb25fca8ecc8ac1e162a7d96bcd5e2..0000000000000000000000000000000000000000
--- a/src/utils/pinch-to-move.js
+++ /dev/null
@@ -1,47 +0,0 @@
-export default class PinchToMove {
-  constructor(el) {
-    this.speed = 0.35;
-    this.el = el;
-    this.onPinch = this.onPinch.bind(this);
-    this.onSpread = this.onSpread.bind(this);
-    this.decay = this.decay.bind(this);
-    document.addEventListener("pinch", this.onPinch);
-    document.addEventListener("spread", this.onSpread);
-
-    this.interval = null;
-    this.decayingSpeed = 0;
-    this.dir = 1;
-  }
-
-  decay() {
-    if (Math.abs(this.decayingSpeed) < 0.01) {
-      window.clearInterval(this.interval);
-      this.interval = null;
-    }
-
-    this.el.emit("move", { axis: [0, this.dir * this.decayingSpeed] });
-    this.decayingSpeed *= 0.95;
-  }
-
-  onPinch(e) {
-    const dist = e.detail.distance * this.speed;
-    this.decayingSpeed = dist;
-    this.dir = -1;
-    this.el.emit("move", { axis: [0, this.dir * dist] });
-
-    //    if (!this.interval) {
-    //      this.interval = window.setInterval(this.decay, 20);
-    //    }
-  }
-
-  onSpread(e) {
-    const dist = e.detail.distance * this.speed;
-    this.decayingSpeed = dist;
-    this.dir = 1;
-    this.el.emit("move", { axis: [0, this.dir * dist] });
-
-    //   if (!this.interval) {
-    //     this.interval = window.setInterval(this.decay, 20);
-    //   }
-  }
-}
diff --git a/src/utils/pinch.js b/src/utils/pinch.js
deleted file mode 100644
index df28f571b163045ab9657a0ed4f14ed989d63fc2..0000000000000000000000000000000000000000
--- a/src/utils/pinch.js
+++ /dev/null
@@ -1,114 +0,0 @@
-export default class Pinch {
-  constructor(el) {
-    this.el = el;
-    this.prevDiff = -1;
-    this.touchCache = [];
-    this.usedTouch = { identifier: -1 };
-
-    this.onTouchMove = this.onTouchMove.bind(this);
-    this.onTouchStart = this.onTouchStart.bind(this);
-    this.onTouchEnd = this.onTouchEnd.bind(this);
-    this.removeTouch = this.removeTouch.bind(this);
-    this.addTouch = this.addTouch.bind(this);
-
-    document.addEventListener("touchmove", this.onTouchMove);
-    document.addEventListener("touchstart", this.onTouchStart);
-    document.addEventListener("touchend", this.onTouchEnd);
-    document.addEventListener("touchcancel", this.onTouchEnd);
-    document.addEventListener("touch-used-by-cursor", ev => {
-      const touch = ev.detail;
-      this.removeTouch(touch);
-      this.usedTouch = touch;
-    });
-  }
-
-  onTouchEnd = ev => {
-    for (let i = 0; i < ev.changedTouches.length; i++) {
-      const touch = ev.changedTouches[i];
-      if (touch.identifier === this.usedTouch.identifier) {
-        this.usedTouch = { identifier: -1 };
-      }
-      this.removeTouch(touch);
-    }
-  };
-
-  onTouchStart = ev => {
-    for (let i = 0; i < ev.touches.length; i++) {
-      const touch = ev.touches[i];
-      if (touch.identifier === this.usedTouch.identifier || touch.clientY / window.innerHeight >= 0.8) {
-        continue;
-      }
-      this.addTouch(touch);
-    }
-  };
-
-  onTouchMove = ev => {
-    const cache = this.touchCache;
-    for (let i = 0; i < ev.touches.length; i++) {
-      const touch = ev.touches[i];
-      if (touch.identifier !== this.usedTouch.identifier) {
-        this.updateTouch(touch);
-      }
-    }
-
-    if (cache.length !== 2) {
-      this.prevDiff = -1;
-      return;
-    }
-    if (window.LookControlsToggle) {
-      window.LookControlsToggle.toggle(false, this);
-    }
-
-    const diff = Pinch.distance(cache[0].clientX, cache[0].clientY, cache[1].clientX, cache[1].clientY);
-
-    if (this.prevDiff > 0) {
-      if (diff > this.prevDiff) {
-        this.el.emit("spread", { distance: diff - this.prevDiff });
-      } else if (diff < this.prevDiff) {
-        this.el.emit("pinch", { distance: this.prevDiff - diff });
-      }
-    }
-
-    this.prevDiff = diff;
-  };
-
-  removeTouch = touch => {
-    for (let i = 0; i < this.touchCache.length; i++) {
-      if (this.touchCache[i].identifier === touch.identifier) {
-        this.touchCache.splice(i, 1);
-        break;
-      }
-    }
-    if (this.touchCache.length < 2) {
-      if (window.LookControlsToggle) {
-        window.LookControlsToggle.toggle(true, this);
-      }
-      this.prevDiff = -1;
-    }
-  };
-
-  addTouch = touch => {
-    for (let i = 0; i < this.touchCache.length; i++) {
-      if (this.touchCache[i].identifier === touch.identifier) {
-        return;
-      }
-    }
-
-    this.touchCache.push(touch);
-  };
-
-  updateTouch = touch => {
-    for (let i = 0; i < this.touchCache.length; i++) {
-      if (this.touchCache[i].identifier === touch.identifier) {
-        this.touchCache[i] = touch;
-        return;
-      }
-    }
-  };
-
-  static distance = (x1, y1, x2, y2) => {
-    const x = x1 - x2;
-    const y = y1 - y2;
-    return Math.sqrt(x * x + y * y);
-  };
-}
diff --git a/src/utils/pointer-look-controls.js b/src/utils/pointer-look-controls.js
deleted file mode 100644
index 227706edb21b7319be8312a3e572245e1b9c2015..0000000000000000000000000000000000000000
--- a/src/utils/pointer-look-controls.js
+++ /dev/null
@@ -1,124 +0,0 @@
-const PI_4 = Math.PI / 4;
-export default class PointerLookControls {
-  constructor(lookControlsEl) {
-    this.xSpeed = 0.005;
-    this.ySpeed = 0.003;
-    this.lookControlsEl = lookControlsEl;
-    this.onTouchStart = this.onTouchStart.bind(this);
-    this.onTouchMove = this.onTouchMove.bind(this);
-    this.onTouchEnd = this.onTouchEnd.bind(this);
-    this.getLookControls = this.getLookControls.bind(this);
-    this.removeTouch = this.removeTouch.bind(this);
-    this.onRotateX = this.onRotateX.bind(this);
-    this.usedTouch = { identifier: -1 };
-    document.addEventListener("touch-used-by-cursor", ev => {
-      const touch = ev.detail;
-      this.removeTouch(touch);
-      this.usedTouch = touch;
-    });
-
-    this.start = this.start.bind(this);
-    this.stop = this.stop.bind(this);
-
-    this.getLookControls();
-    this.cache = [];
-
-    document.addEventListener("touchstart", this.onTouchStart);
-    document.addEventListener("touchmove", this.onTouchMove);
-    document.addEventListener("touchend", this.onTouchEnd);
-    document.addEventListener("touchcancel", this.onTouchEnd);
-    AFRAME.scenes[0].sceneEl.addEventListener("rotateX", this.onRotateX);
-  }
-
-  onRotateX(e) {
-    const dY = e.detail.value;
-    this.pitchObject.rotation.x += dY * 0.02;
-    this.pitchObject.rotation.x = Math.max(-PI_4, Math.min(PI_4, this.pitchObject.rotation.x));
-  }
-
-  getLookControls() {
-    this.lookControls = this.lookControlsEl.components["look-controls"];
-    this.yawObject = this.lookControls.yawObject;
-    this.pitchObject = this.lookControls.pitchObject;
-  }
-
-  start() {
-    if (!this.lookControls) {
-      this.getLookControls();
-    }
-    this.enabled = true;
-  }
-
-  stop() {
-    this.enabled = false;
-  }
-
-  onTouchStart(ev) {
-    for (let i = 0; i < ev.touches.length; i++) {
-      const touch = ev.touches[i];
-      if (touch.identifier === this.usedTouch.identifier || touch.clientY / window.innerHeight >= 0.8) {
-        continue;
-      }
-    }
-  }
-
-  onTouchMove(ev) {
-    const cache = this.cache;
-    for (let i = 0; i < ev.touches.length; i++) {
-      const touch = ev.touches[i];
-
-      if (touch.identifier === this.usedTouch.identifier || touch.clientY / window.innerHeight >= 0.8) {
-        continue;
-      }
-
-      let cachedTouch = null;
-      for (let j = 0; j < cache.length; j++) {
-        if (touch.identifier === cache[j].identifier) {
-          cachedTouch = cache[j];
-          cache[j] = touch;
-          break;
-        }
-      }
-      if (!cachedTouch) {
-        this.cache.push(touch);
-        continue;
-      }
-
-      if (!this.enabled) {
-        continue;
-      }
-      const dX = touch.clientX - cachedTouch.clientX;
-      const dY = touch.clientY - cachedTouch.clientY;
-
-      this.yawObject.rotation.y -= dX * this.xSpeed;
-      this.pitchObject.rotation.x -= dY * this.ySpeed;
-      this.pitchObject.rotation.x = Math.max(-PI_4, Math.min(PI_4, this.pitchObject.rotation.x));
-    }
-  }
-
-  onTouchEnd(ev) {
-    for (let i = 0; i < ev.changedTouches.length; i++) {
-      const touch = ev.changedTouches[i];
-      this.removeTouch(touch);
-      if (touch.identifier === this.usedTouch.identifier) {
-        this.usedTouch = { identifier: -1 };
-      }
-    }
-  }
-
-  removeTouch(touch) {
-    const cache = this.cache;
-    for (let i = 0; i < cache.length; i++) {
-      if (cache[i].identifier == touch.identifier) {
-        cache.splice(i, 1);
-        break;
-      }
-    }
-  }
-
-  static distance = (x1, y1, x2, y2) => {
-    const x = x1 - x2;
-    const y = y1 - y2;
-    return Math.sqrt(x * x + y * y);
-  };
-}
diff --git a/src/utils/touch-events-handler.js b/src/utils/touch-events-handler.js
new file mode 100644
index 0000000000000000000000000000000000000000..5532db5830f90f6cfe2b43cd7432e73814b10192
--- /dev/null
+++ b/src/utils/touch-events-handler.js
@@ -0,0 +1,176 @@
+const VIRTUAL_JOYSTICK_HEIGHT = 0.8;
+const HORIZONTAL_LOOK_SPEED = 0.005;
+const VERTICAL_LOOK_SPEED = 0.003;
+const PI_4 = Math.PI / 4;
+
+export default class TouchEventsHandler {
+  constructor() {
+    this.cursor = null;
+    this.lookControls = null;
+    this.pinchEmitter = null;
+    this.touches = [];
+    this.touchReservedForCursor = null;
+    this.touchesReservedForPinch = [];
+    this.touchReservedForLookControls = null;
+    this.needsPinch = false;
+    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);
+    this.handleTouchMove = this.handleTouchMove.bind(this);
+    this.singleTouchMove = this.singleTouchMove.bind(this);
+    this.handleTouchEnd = this.handleTouchEnd.bind(this);
+    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();
+    }
+  }
+
+  isReady() {
+    return this.cursor && this.lookControls && this.pinchEmitter;
+  }
+
+  addEventListeners() {
+    document.addEventListener("touchstart", this.handleTouchStart);
+    document.addEventListener("touchmove", this.handleTouchMove);
+    document.addEventListener("touchend", this.handleTouchEnd);
+    document.addEventListener("touchcancel", this.handleTouchEnd);
+  }
+
+  handleTouchStart(e) {
+    Array.prototype.forEach.call(e.touches, this.singleTouchStart);
+  }
+
+  singleTouchStart(touch) {
+    if (touch.clientY / window.innerHeight >= VIRTUAL_JOYSTICK_HEIGHT) return;
+    if (!this.touchReservedForCursor && this.cursor.handleTouchStart(touch)) {
+      this.touchReservedForCursor = touch;
+      return;
+    }
+    this.touches.push(touch);
+  }
+
+  handleTouchMove(e) {
+    Array.prototype.forEach.call(e.touches, this.singleTouchMove);
+    if (this.needsPinch) {
+      this.pinch();
+      this.needsPinch = false;
+    }
+  }
+
+  singleTouchMove(touch) {
+    if (this.touchReservedForCursor && touch.identifier === this.touchReservedForCursor.identifier) {
+      this.cursor.handleTouchMove(touch);
+      return;
+    }
+    if (touch.clientY / window.innerHeight >= VIRTUAL_JOYSTICK_HEIGHT) return;
+    if (!this.touches.some(t => touch.identifier === t.identifier)) return;
+
+    let pinchIndex = this.touchesReservedForPinch.findIndex(t => touch.identifier === t.identifier);
+    if (pinchIndex !== -1) {
+      this.touchesReservedForPinch[pinchIndex] = touch;
+    } else if (this.touchesReservedForPinch.length < 2) {
+      this.touchesReservedForPinch.push(touch);
+      pinchIndex = this.touchesReservedForPinch.length - 1;
+    }
+    if (this.touchesReservedForPinch.length == 2 && pinchIndex !== -1) {
+      if (this.touchReservedForLookControls && touch.identifier === this.touchReservedForLookControls.identifier) {
+        this.touchReservedForLookControls = null;
+      }
+      this.needsPinch = true;
+      return;
+    }
+
+    if (!this.touchReservedForLookControls) {
+      this.touchReservedForLookControls = touch;
+    }
+    if (touch.identifier === this.touchReservedForLookControls.identifier) {
+      if (!this.touchReservedForCursor) {
+        this.cursor.handleTouchMove(touch);
+      }
+      this.look(this.touchReservedForLookControls, touch);
+      this.touchReservedForLookControls = touch;
+      return;
+    }
+  }
+
+  pinch() {
+    const t1 = this.touchesReservedForPinch[0];
+    const t2 = this.touchesReservedForPinch[1];
+    const isNewPinch = t1.identifier !== this.pinchTouchId1 || t2.identifier !== this.pinchTouchId2;
+    const pinchDistance = TouchEventsHandler.distance(t1.clientX, t1.clientY, t2.clientX, t2.clientY);
+    this.pinchEmitter.emit("pinch", { isNewPinch: isNewPinch, distance: pinchDistance });
+    this.pinchTouchId1 = t1.identifier;
+    this.pinchTouchId2 = t2.identifier;
+  }
+
+  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)
+    );
+  }
+
+  handleTouchEnd(e) {
+    Array.prototype.forEach.call(e.changedTouches, this.singleTouchEnd);
+  }
+
+  singleTouchEnd(touch) {
+    const touchIndex = this.touches.findIndex(t => touch.identifier === t.identifier);
+    if (touchIndex === -1) return;
+    this.touches.splice(touchIndex, 1);
+
+    if (this.touchReservedForCursor && touch.identifier === this.touchReservedForCursor.identifier) {
+      this.cursor.handleTouchEnd(touch);
+      this.touchReservedForCursor = null;
+      return;
+    }
+
+    const pinchIndex = this.touchesReservedForPinch.findIndex(t => touch.identifier === t.identifier);
+    if (pinchIndex !== -1) {
+      this.touchesReservedForPinch.splice(pinchIndex, 1);
+      this.pinchTouchId1 = -1;
+      this.pinchTouchId2 = -1;
+    }
+
+    if (this.touchReservedForLookControls && touch.identifier === this.touchReservedForLookControls.identifier) {
+      this.touchReservedForLookControls = null;
+    }
+  }
+
+  static distance = (x1, y1, x2, y2) => {
+    const x = x1 - x2;
+    const y = y1 - y2;
+    return Math.sqrt(x * x + y * y);
+  };
+}