diff --git a/src/components/cursor-controller.js b/src/components/cursor-controller.js
index 6183eb6664331d6615d0e53776556b03d9c9ad58..7b2c16f77ee185d432581bea38806558e7448dce 100644
--- a/src/components/cursor-controller.js
+++ b/src/components/cursor-controller.js
@@ -43,9 +43,9 @@ AFRAME.registerComponent("cursor-controller", {
     this.data.cursor.setAttribute("material", { color: this.data.cursorColorUnhovered });
 
     const functionNames = [
-      "_handleTouchStart",
-      "_handleTouchMove",
-      "_handleTouchEnd",
+      "_handlePointerDown",
+      "_handlePointerMove",
+      "_handlePointerUp",
       "_handleMouseDown",
       "_handleMouseMove",
       "_handleMouseUp",
@@ -81,10 +81,10 @@ 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("pointerdown", this._handlePointerDown);
+    document.addEventListener("pointermove", this._handlePointerMove);
+    document.addEventListener("pointerup", this._handlePointerUp);
+    document.addEventListener("pointercancel", this._handlePointerUp);
     document.addEventListener("mousedown", this._handleMouseDown);
     document.addEventListener("mousemove", this._handleMouseMove);
     document.addEventListener("mouseup", this._handleMouseUp);
@@ -104,10 +104,10 @@ 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("pointerdown", this._handlePointerDown);
+    document.removeEventListener("pointermove", this._handlePointerMove);
+    document.removeEventListener("pointerup", this._handlePointerUp);
+    document.removeEventListener("pointercancel", this._handlePointerUp);
     document.removeEventListener("mousedown", this._handleMouseDown);
     document.removeEventListener("mousemove", this._handleMouseMove);
     document.removeEventListener("mouseup", this._handleMouseUp);
@@ -229,14 +229,7 @@ AFRAME.registerComponent("cursor-controller", {
   },
 
   _setLookControlsEnabled(enabled) {
-    const lookControls = this.data.camera.components["look-controls"];
-    if (lookControls) {
-      if (enabled) {
-        lookControls.play();
-      } else {
-        lookControls.pause();
-      }
-    }
+    window.LookControlsToggle.toggle(enabled, this);
   },
 
   _startTeleport: function() {
@@ -257,17 +250,10 @@ AFRAME.registerComponent("cursor-controller", {
     this._setCursorVisibility(true);
   },
 
-  _handleTouchStart: function(e) {
-    if (!this.isMobile || this.hasPointingDevice || this.activeTouch) return;
+  _handlePointerDown: function(e) {
+    if (!this.isMobile || this.hasPointingDevice || this.activeTouch || e.clientY / window.innerHeight >= 0.8) return;
 
-    for (let i = e.touches.length - 1; i >= 0; i--) {
-      const touch = e.touches[i];
-      if (touch.clientY / window.innerHeight < 0.8) {
-        this.activeTouch = touch;
-        break;
-      }
-    }
-    if (!this.activeTouch) return;
+    this.activeTouch = e;
 
     // Update the ray and cursor positions
     const raycasterComp = this.el.components.raycaster;
@@ -289,31 +275,25 @@ AFRAME.registerComponent("cursor-controller", {
     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", {});
   },
 
-  _handleTouchMove: function(e) {
+  _handlePointerMove: function(e) {
     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 < 0.8) ||
-        (this.activeTouch && touch.identifier === this.activeTouch.identifier)
-      ) {
-        this.mousePos.set(touch.clientX / window.innerWidth * 2 - 1, -(touch.clientY / window.innerHeight) * 2 + 1);
-        return;
-      }
+    if (
+      (!this.activeTouch && e.clientY / window.innerHeight < 0.8) ||
+      (this.activeTouch && e.pointerId === this.activeTouch.pointerId)
+    ) {
+      this.mousePos.set(e.clientX / window.innerWidth * 2 - 1, -(e.clientY / window.innerHeight) * 2 + 1);
+      return;
     }
   },
 
-  _handleTouchEnd: function(e) {
-    if (
-      !this.isMobile ||
-      this.hasPointingDevice ||
-      !this.activeTouch ||
-      this.some(e.touches, touch => touch.identifier === this.activeTouch.identifier)
-    ) {
+  _handlePointerUp: function(e) {
+    if (!this.isMobile || this.hasPointingDevice || !this.activeTouch || e.pointerId !== this.activeTouch.pointerId) {
       return;
     }
 
diff --git a/src/hub.js b/src/hub.js
index 215965e98ac7bd794159ec6a31bcea80aa9a22ab..e7b05cc5824738a4d6df438ef03af02bce7bf3d2 100644
--- a/src/hub.js
+++ b/src/hub.js
@@ -110,6 +110,10 @@ 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";
 
 window.RENDER_ORDER = {
   HUD_BACKGROUND: 1,
@@ -210,6 +214,9 @@ const onReady = async () => {
   const enterScene = async (mediaStream, enterInVR, janusRoomId) => {
     const scene = document.querySelector("a-scene");
     scene.renderer.sortObjects = true;
+    const pinch = new Pinch(scene);
+    const pinchToMove = new PinchToMove(scene);
+    window.p = pinchToMove;
     const playerRig = document.querySelector("#player-rig");
     document.querySelector("a-scene canvas").classList.remove("blurred");
     scene.render();
@@ -220,7 +227,10 @@ const onReady = async () => {
 
     AFRAME.registerInputActions(inGameActions, "default");
 
-    document.querySelector("#player-camera").setAttribute("look-controls", "");
+    const camera = document.querySelector("#player-camera");
+    camera.setAttribute("look-controls", "touchEnabled", false);
+    window.PointerLookControls = new PointerLookControls(camera);
+    window.LookControlsToggle = new LookControlsToggle(camera, window.PointerLookControls);
 
     scene.setAttribute("networked-scene", {
       room: janusRoomId,
diff --git a/src/utils/look-controls-toggle.js b/src/utils/look-controls-toggle.js
new file mode 100644
index 0000000000000000000000000000000000000000..d7f21347efb903a4807fc391e9c76154c1b012b9
--- /dev/null
+++ b/src/utils/look-controls-toggle.js
@@ -0,0 +1,35 @@
+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 (let 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
new file mode 100644
index 0000000000000000000000000000000000000000..093fc9ac72d5633bbbd6b51b2a025acb79de9776
--- /dev/null
+++ b/src/utils/pinch-to-move.js
@@ -0,0 +1,45 @@
+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.93;
+  }
+
+  onPinch(e) {
+    const dist = e.detail.distance * this.speed;
+    this.decayingSpeed = dist;
+    this.dir = -1;
+
+    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;
+
+    if (!this.interval) {
+      this.interval = window.setInterval(this.decay, 20);
+    }
+  }
+}
diff --git a/src/utils/pinch.js b/src/utils/pinch.js
new file mode 100644
index 0000000000000000000000000000000000000000..cbb2ba5036307adc8dd918897fe622380834445d
--- /dev/null
+++ b/src/utils/pinch.js
@@ -0,0 +1,76 @@
+export default class Pinch {
+  constructor(el) {
+    this.el = el;
+    this.prevDiff = -1;
+    this.evCache = [];
+
+    this.onPointerMove = this.onPointerMove.bind(this);
+    this.onPointerDown = this.onPointerDown.bind(this);
+    this.onPointerUp = this.onPointerUp.bind(this);
+    this.removeEvent = this.removeEvent.bind(this);
+
+    document.addEventListener("pointermove", this.onPointerMove);
+    document.addEventListener("pointerdown", this.onPointerDown);
+    document.addEventListener("pointerup", this.onPointerUp);
+    document.addEventListener("pointercancel", this.onPointerUp);
+    document.addEventListener("touch-used-by-cursor", this.onPointerUp);
+  }
+
+  onPointerUp = ev => {
+    this.removeEvent(ev);
+    if (this.evCache.length < 2) {
+      window.LookControlsToggle.toggle(true, this);
+      this.prevDiff = -1;
+    }
+  };
+
+  onPointerDown = ev => {
+    if (ev.isUsedByCursor || ev.clientY / window.innerHeight >= 0.8) {
+      return;
+    }
+    this.evCache.push(ev);
+  };
+
+  onPointerMove = ev => {
+    const cache = this.evCache;
+
+    for (var i = 0; i < cache.length; i++) {
+      if (ev.pointerId === cache[i].pointerId) {
+        cache[i] = ev;
+        break;
+      }
+    }
+
+    if (cache.length !== 2) {
+      return;
+    }
+    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;
+  };
+
+  removeEvent = ev => {
+    for (let i = 0; i < this.evCache.length; i++) {
+      if (this.evCache[i].pointerId == ev.pointerId) {
+        this.evCache.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/pointer-look-controls.js b/src/utils/pointer-look-controls.js
new file mode 100644
index 0000000000000000000000000000000000000000..49485c8a1273fdb90f05966c85be39de26d954c4
--- /dev/null
+++ b/src/utils/pointer-look-controls.js
@@ -0,0 +1,96 @@
+const PI_2 = Math.PI / 2;
+export default class PointerLookControls {
+  constructor(lookControlsEl) {
+    this.xSpeed = 0.005;
+    this.ySpeed = 0.003;
+    this.lookControlsEl = lookControlsEl;
+    this.onPointerDown = this.onPointerDown.bind(this);
+    this.onPointerMove = this.onPointerMove.bind(this);
+    this.onPointerUp = this.onPointerUp.bind(this);
+    this.getLookControls = this.getLookControls.bind(this);
+    this.removeEvent = this.removeEvent.bind(this);
+    document.addEventListener("touch-used-by-cursor", this.onPointerUp);
+    this.start = this.start.bind(this);
+    this.stop = this.stop.bind(this);
+
+    this.getLookControls();
+    this.cache = [];
+  }
+
+  getLookControls() {
+    this.lookControls = this.lookControlsEl.components["look-controls"];
+    this.yawObject = this.lookControls.yawObject;
+    this.pitchObject = this.lookControls.pitchObject;
+  }
+
+  start() {
+    document.addEventListener("pointerdown", this.onPointerDown);
+    document.addEventListener("pointermove", this.onPointerMove);
+    document.addEventListener("pointerup", this.onPointerUp);
+    document.addEventListener("pointercancel", this.onPointerUp);
+    if (!this.lookControls) {
+      this.getLookControls();
+    }
+  }
+
+  stop() {
+    document.removeEventListener("pointerdown", this.onPointerDown);
+    document.removeEventListener("pointermove", this.onPointerMove);
+    document.removeEventListener("pointerup", this.onPointerUp);
+    document.removeEventListener("pointercancel", this.onPointerUp);
+    this.cache = [];
+  }
+
+  onPointerDown(ev) {
+    if (ev.isUsedByCursor || ev.clientY / window.innerHeight >= 0.8) {
+      return;
+    }
+    this.cache.push(ev);
+  }
+
+  onPointerMove(ev) {
+    const cache = this.cache;
+    if (ev.isUsedByCursor || ev.clientY / window.innerHeight >= 0.8) {
+      return;
+    }
+
+    let cachedEv = null;
+    for (var i = 0; i < cache.length; i++) {
+      if (ev.pointerId === cache[i].pointerId) {
+        cachedEv = cache[i];
+        cache[i] = ev;
+        break;
+      }
+    }
+    if (!cachedEv) {
+      return;
+    }
+
+    const dX = ev.clientX - cachedEv.clientX;
+    const dY = ev.clientY - cachedEv.clientY;
+
+    this.yawObject.rotation.y -= dX * this.xSpeed;
+    this.pitchObject.rotation.x -= dY * this.ySpeed;
+    this.pitchObject.rotation.x = Math.max(-PI_2, Math.min(PI_2, this.pitchObject.rotation.x));
+  }
+
+  onPointerUp(ev) {
+    this.removeEvent(ev);
+  }
+
+  removeEvent(ev) {
+    const cache = this.cache;
+    for (let i = 0; i < cache.length; i++) {
+      if (cache[i].pointerId == ev.pointerId) {
+        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);
+  };
+}