---
title: "Input, touch & pointer gating"
slug: /workflows/input-touch/
markdown: /workflows/input-touch.md
section: architecture-csharp
tier: 2
summary: "Input System wiring and touch traps that never reproduce with a mouse."
sources: ['unity/input-touch.md']
canonical: https://unityworkflowlibrary.dev/workflows/input-touch/
read_first: /read-first.md
---

> Read first, on every prompt: [non-destructive rules](/read-first.md).

# Input, touch & pointer gating

> Input System wiring and touch traps that never reproduce with a mouse.

Input System wiring plus the touch-specific traps that never reproduce on desktop, because a mouse hovers and a finger does not.

- **CinemachineOrbitalFollow (Sphere style): `RadialAxis.Value` is a SCALE on `Radius`, not
  meters.** Camera distance = `Radius × RadialAxis.Value`, so code that drives the radial axis in
  meters (zoom 8–120 style) must set `orbit.Radius = 1` — an authored Radius of 10 silently
  multiplies every zoom ×10 (symptom: camera hundreds of meters out, map a speck, units
  "missing"). Enforce `Radius = 1` in the same code that pushes zoom ranges/values so any scene
  wiring stays correct. (Seen in a survival RTS: match camera 480 m out from zoom 48 ×
  authored Radius 10.)

- **World-input gates (click-through, scroll-zoom, orbit-through) must be GENERAL — one
  `EventSystem.current.IsPointerOverGameObject` check, never a hand-maintained per-panel list.**
  UI Toolkit runtime panels register `PanelRaycaster`s with the EventSystem, so that one call covers
  EVERY panel, current and future, and respects `pickingMode`. A per-panel OR-chain of
  `BlocksWorldClick`/rect tests goes stale the day the next panel ships and the bug re-reports forever
  (burned repeatedly; final straw — scroll zoomed the camera through every panel added after
  the chain). Keep per-panel rect methods only for semantics a pick can't express (e.g. "this modal
  blocks the whole screen"). Caveat: call it from `Update`/`LateUpdate` and cache the bool — calling it
  inside an `InputAction` callback returns wrong results and logs a warning.

- **Discrete input is event-driven, never polled every frame; a stable handle is resolved ONCE, not
  re-fetched in `Update`.** For a one-shot action — a key press, a button, a toggle — bind an
  `InputAction` and handle its `performed` callback (subscribe in `OnEnable`, `-=` + `Dispose` in
  `OnDisable`), so there is NO per-frame poll at all (often no `Update` at all). Do NOT sit a
  `Keyboard.current[key].wasPressedThisFrame` / `someKey.wasPressedThisFrame` check inside
  `Update`/`LateUpdate` to catch a discrete press — that polls every frame for what is an event.
  Continuous input (mouse position, scroll, a held button for drag/pan/zoom, an axis) DOES read per
  frame, but off a cached action/device and behind an early-out. More generally: any handle that
  doesn't change frame-to-frame — an input device (`Keyboard.current`/`Mouse.current`), a component
  ref, a singleton, an `EntityQuery`, `Camera.main` — is resolved once in `Awake`/`OnEnable`/`OnCreate`
  and cached; re-grabbing it inside a per-frame method is the smell even when the call looks cheap.
  (New Input System note: legacy `UnityEngine.Input.*` THROWS at runtime when Active Input Handling is
  "Input System Package" — it still compiles, so it only fails in Play. Seen in practice: a debug
  hotkey polled `Keyboard.current` per frame in `Update` for a discrete digit press; rewritten to a
  callback-driven `InputAction` with no `Update`.)

- **TOUCH-UI-GATE-SYNC-PICK — `EventSystem.IsPointerOverGameObject(touchId)` misses the touch
  PRESS frame; gate world input with a synchronous UITK pick.** Touch has no hover — the pointer
  materializes exactly at press, and the UI module often has not processed it yet when gameplay
  polls, so on device the gate reads false for the first frame(s): a tap on a HUD button ALSO
  lands as a world tap (deselect → "the panel closed itself"), and camera drags start from under
  buttons. Desktop is immune (mouse hover pre-arms the pointer), so this ships silently and only
  reports from device builds. Fix shape: register each runtime panel root, then hit-test
  synchronously — `RuntimePanelUtils.ScreenToPanel(root.panel, new Vector2(x, Screen.height - y))`
  + `panel.Pick(...)` (respects pickingMode; input positions are bottom-left origin, panels
  top-left). Keep the EventSystem check only as a fallback for unregistered surfaces. (Seen in a mobile strategy game: Android: every HUD tap fell through and closed the selection panel.)

- **PINCH-REANCHOR-TOPOLOGY — a touch gesture driver must re-anchor its cached sample whenever
  finger topology changes, keyed by touchId.** Deltas are only valid against the SAME fingers:
  on 2→1 (pinch end) the cached centroid is the two-finger midpoint, so the next 1-finger frame
  applies half a pinch-span as a pan — the camera "snaps somewhere else" after every zoom; an
  activeTouches order/id swap flips the twist angle up to 180° in one frame. Store the touchIds
  the sample was taken from; on any mismatch (count OR ids) cache fresh and skip that frame's
  delta instead of applying it. (Seen in a mobile strategy game: Android pinch zoom.)

- **GESTURE-DEADZONE-CUMULATIVE — a gesture deadzone is CUMULATIVE from the gesture anchor with a
  latch, never a per-frame delta test against a reference you re-cache every frame.** The broken
  shape: `if (abs(delta) >= deadzone) apply(delta); prev = current;` — a deliberate slow twist/drag
  moves ~1-2°/frame, never crosses the deadzone in one frame, and the re-cached reference erases the
  accumulation, so the gesture is STRUCTURALLY stuck (only a violent flick registers, which reads as
  "rotation randomly jumps"). Correct shape: bank the delta from the gesture-start anchor; when the
  BANKED total crosses the deadzone, latch the gesture active for the rest of the touch and stream
  per-frame deltas (feed only the overshoot past the deadzone on the latch frame so it eases in
  instead of snapping the wind-up). Reset bank+latch at every fresh anchor (new pair, topology
  change — see PINCH-REANCHOR-TOPOLOGY). Also: check which quantity a pinch SINK scales — if it
  scales camera DISTANCE, the driver must send `pow(span ratio, -gain)`; spread = zoom IN is the
  platform convention. (Seen in a mobile strategy game: mobile twist rotation "gets stuck" — 4°
  per-frame deadzone vs re-anchored angle; pinch also zoomed backward.)

- **TOUCH-PICK-PHYSICAL — input thresholds are physical units (dp), and world pick volumes are
  CAPSULES, not spheres at one anchor height.** Two paired reasons "tapping things on device feels
  dead while FPS is fine": (1) a raw-pixel tap-slop/tolerance constant tuned on desktop (10 px) is
  ~0.5 mm at phone DPI — a normal finger rolls 15-40 px between press and release, so most honest
  taps reclassify as drags and the release picks nothing. Scale every touch threshold like Android
  touch slop: `px = base * max(1, Screen.dpi / 160)`; mouse paths keep raw pixels. (2) a pick test
  of ray-vs-sphere at a fixed low anchor makes pickability CAMERA-ANGLE-dependent — at shallow
  pitch the tall visible mesh projects far above its little sphere, so taps on the body sail over
  (reported as "some angles are harder to select"). Test ray-vs-vertical-SEGMENT (feet→top capsule)
  instead — angle-independent for tall objects; and never exclude a category (structures) from the
  screen-space near-miss fallback. (Seen in a mobile strategy game: building selection "super
  unresponsive / delayed / angle-dependent" on Android.)

- **DESKTOP-HOVER-MOBILE-TAP-SELECT — every desktop hover-to-inspect surface must expose the same information as a tap-select state on touch; a second tap closes it.** Touch has no hover, so a hover-only tooltip silently removes the information on phones. Keep the selected state visible until the player dismisses it; do not rely on simulated hover.

- **CM3-DEPRECATED-FREELOOK — before rewriting a Cinemachine 2 rig for Cinemachine 3, check the
  deprecated compat layer: CM3 still ships `CinemachineFreeLook`/`CinemachineVirtualCameraBase`
  with the old `m_XAxis`/`m_YAxis` `AxisState` fields under the `Unity.Cinemachine` namespace,
  marked `[Obsolete]` but fully functional.** Authored scene rigs (Follow/LookAt, orbit rigs,
  damping) survive the package upgrade untouched, so the cheapest correct migration of driver code
  is often: swap `using Cinemachine;` → `using Unity.Cinemachine;`, add `#pragma warning disable
  0618`, and keep the original body — not a rewrite to OrbitalFollow. Verify with reflection when
  unsure: `Type.GetType("Unity.Cinemachine.CinemachineFreeLook, Unity.Cinemachine")` and list its
  axis fields. Rewrite to the CM3-native components only when actually touching rig behaviour.
  (Proven a tower-defense game: developer's commented-out FreeLook driver restored verbatim after
  the CM2→CM3 move that had been misread as "API gone".)

- **STARTER-ASSETS-BUTTONS-LATCH — under `PlayerInput` SendMessages, `StarterAssetsInputs` button
  fields LATCH on press** (`OnJump` fires on press only; the stock controllers clear `_input.jump`
  themselves as consumption). A binder that disables those controllers and reads the fields must
  consume the latch after reading, and an input lock must ZERO those fields before local intent is
  published — locking the HUD or cursor alone leaves jump held forever. (a survival game)

- **STREAMED-BOUNDS-NEVER-LATCH — a clamp pulled from streamed, frame-budgeted content must re-pull
  while the extents grow, and once in-game only ever WIDEN.** A one-shot `_boundsPulled` latched on
  the first non-empty rect — a single corner tile — and trapped camera pan in that pocket for the
  whole session. Re-centre only during loading; expand afterwards. (a project `docs/pitfalls/camera.md:25`.)
- **INPUT-FRAME-HAS-ONE-OWNER — an armed mode (build, demolish, target) OWNS its input frames; one
  physical click never reaches two consumers.** The right-click that exited placement was also run
  through the ordinary right-click chain the same frame and auto-built on the pawn it landed on.
  Every armed mode implements both halves: consume the frame, and the general chain checks the
  armed state first. (a project `docs/pitfalls/building.md:62`, `storage-haul.md:43`.)
- **CAMERA-RIG-INPUT-TRAPS.** Scroll zoom is PROPORTIONAL (a fraction of the current distance per
  notch), never a fixed step — one unit per notch crawls at radius 60 and lurches at radius 2; an
  orbit cursor-hide must not use `CursorLockMode.Locked` when the same button also issues orders
  (locking breaks the pointer raycasts); and a `CinemachineCamera` needs a rotation-control
  component plus BOTH `TrackingTarget` and `LookAtTarget` or it orbits without aiming, with the
  drag gated by a `OneModifier` composite in the actions asset. (a project `docs/pitfalls/camera.md:17-31`.)

- **DEVICE-SIMULATOR-TAB-DISABLES-THE-MOUSE — a Device Simulator window that exists anywhere in
  the editor layout, even as a hidden tab behind another window, owns `Screen` and switches the
  native Mouse and Pen OFF.** Its Input System plugin disables them in `OnCreate` and again on every
  `Added`/`Reconnected` device change, so the device reset that comes with each Play re-disables
  them; the game then accepts only touches injected from the simulator's own screen, which nobody
  can see. Symptom: the Game view renders, nothing responds, nothing logs, and "it worked last
  Play" (the simulator tab was in front then). Probe `InputSystem.devices`: `Mouse (disabled)`
  next to `Device Simulator Touchscreen` is the whole diagnosis - close the simulator tab or bring
  it to the front and click there, and leave the game's input code alone. A synthetic
  `QueueStateEvent` cannot stand in for the click (INJECTED-INPUT-RESPECTS-GAME-VIEW-FOCUS in
  the [Live play testing via Unity MCP](/workflows/live-play-testing-unity-mcp/) page); read the device list instead. (Seen in practice: Unity 6000.5 + Input
  System 1.20: a card game's Home screen dead for a whole session, blamed on the second match.)
