Your game plays great with keyboard and mouse. Now you're building for mobile and you need touch controls. The jump from physical input to virtual input is deceptively tricky — a bad virtual joystick feels worse than no joystick at all. This guide walks through every touch control type you'll need, with production-ready code and design principles that make the difference between "frustrating" and "invisible" controls.
TL;DR: This tutorial covers five core mobile control patterns: virtual joystick, on-screen action buttons, swipe gesture detection, pinch-to-zoom camera, and safe area responsive scaling. Each section includes complete C# code, Inspector setup instructions, and UX guidance. Every script is available for free download, or grab the full Mobile Controls Kit for a pre-built, drop-in solution.
We'll build each control independently so you can mix and match for your specific game. A top-down RPG might need a joystick and action buttons. A runner might need swipe and tap. A strategy game might need pinch-to-zoom and drag-to-pan. Take what you need.
Planning Your Mobile Controls
Before writing code, decide on your control scheme by answering these questions:
- How many simultaneous inputs? A joystick + attack button means two thumbs, which is the maximum for comfortable play. Adding a third simultaneous input (e.g., aim) requires rethinking the UX entirely.
- What's the precision requirement? A twin-stick shooter needs precise directional input. A puzzle game needs only tap targets. Match the control complexity to the gameplay precision.
- What screen real estate can you sacrifice? Touch controls overlay gameplay. A full-size joystick in the bottom-left corner blocks roughly 15% of the screen. Design your game camera and UI around this.
- Do controls need to be discoverable? Always-visible controls (fixed position joystick) are safe for all audiences. Invisible gesture controls (swipe anywhere) are more elegant but require onboarding.
Golden rule: The best mobile controls are the ones the player stops thinking about after 10 seconds. If the controls are the challenge, your game has a UX problem, not a difficulty curve.
Virtual Joystick for Movement
The virtual joystick is the most common mobile control for games that need directional movement. Our Touch Joystick script provides a polished, configurable version. Here's the implementation:
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
public class VirtualJoystick : MonoBehaviour, IPointerDownHandler, IDragHandler, IPointerUpHandler
{
[SerializeField] private RectTransform background;
[SerializeField] private RectTransform handle;
[SerializeField] private float handleRange = 1f;
[SerializeField] private float deadZone = 0.1f;
[SerializeField] private bool isDynamic = false; // appears at touch position
private Canvas canvas;
private Camera canvasCamera;
private Vector2 input = Vector2.zero;
private Vector2 defaultPosition;
public Vector2 Input => input;
public float Horizontal => input.x;
public float Vertical => input.y;
void Start()
{
canvas = GetComponentInParent<Canvas>();
canvasCamera = canvas.renderMode == RenderMode.ScreenSpaceCamera ? canvas.worldCamera : null;
defaultPosition = background.anchoredPosition;
if (isDynamic)
background.gameObject.SetActive(false);
}
public void OnPointerDown(PointerEventData eventData)
{
if (isDynamic)
{
background.gameObject.SetActive(true);
RectTransformUtility.ScreenPointToLocalPointInRectangle(
canvas.transform as RectTransform, eventData.position,
canvasCamera, out Vector2 localPoint);
background.anchoredPosition = localPoint;
}
OnDrag(eventData);
}
public void OnDrag(PointerEventData eventData)
{
RectTransformUtility.ScreenPointToLocalPointInRectangle(
background, eventData.position, canvasCamera, out Vector2 localPoint);
// Normalize to -1..1 range
Vector2 radius = background.sizeDelta / 2f;
input = localPoint / radius;
// Clamp to unit circle and apply dead zone
input = Vector2.ClampMagnitude(input, 1f);
if (input.magnitude < deadZone) input = Vector2.zero;
// Move handle visual
handle.anchoredPosition = input * radius * handleRange;
}
public void OnPointerUp(PointerEventData eventData)
{
input = Vector2.zero;
handle.anchoredPosition = Vector2.zero;
if (isDynamic)
{
background.gameObject.SetActive(false);
background.anchoredPosition = defaultPosition;
}
}
}To set up the joystick in your scene: create a Canvas (Screen Space - Overlay), add an Image for the joystick background (circular, semi-transparent), add a child Image for the handle (smaller circle, opaque). Assign both RectTransforms to the script fields. Set the anchor to bottom-left and position it with comfortable thumb reach — typically 150-200px from the screen edges.
The isDynamic option makes the joystick appear wherever the player first touches, which is popular in games like Brawl Stars. It's more comfortable for extended play because the player's thumb naturally rests at different positions, but it requires a visual hint ("touch to move" text or a subtle indicator) for discoverability.
To read the joystick input from your player controller, replace Input.GetAxis with the joystick's output:
// In your player movement script
[SerializeField] private VirtualJoystick joystick;
void FixedUpdate()
{
Vector2 move = new Vector2(joystick.Horizontal, joystick.Vertical);
rb.AddForce(move * moveSpeed, ForceMode2D.Force);
}On-Screen Action Buttons
Action buttons handle discrete inputs: jump, attack, interact, use item. Our Touch Button script provides held, pressed, and released states just like physical buttons:
using UnityEngine;
using UnityEngine.EventSystems;
public class TouchButton : MonoBehaviour, IPointerDownHandler, IPointerUpHandler
{
public bool IsPressed { get; private set; }
public bool WasPressedThisFrame { get; private set; }
public bool WasReleasedThisFrame { get; private set; }
private bool pressedLastFrame;
void LateUpdate()
{
WasPressedThisFrame = IsPressed && !pressedLastFrame;
WasReleasedThisFrame = !IsPressed && pressedLastFrame;
pressedLastFrame = IsPressed;
}
public void OnPointerDown(PointerEventData eventData)
{
IsPressed = true;
}
public void OnPointerUp(PointerEventData eventData)
{
IsPressed = false;
}
}Use WasPressedThisFrame for one-shot actions (jump, attack) and IsPressed for continuous actions (sprint, charge). Place buttons on the right side of the screen — the dominant thumb for most players. Make them at least 80x80px with generous spacing to avoid misclicks. Use semi-transparent backgrounds so they don't obscure the game.
In your player controller, replace Input.GetButtonDown("Jump") with:
[SerializeField] private TouchButton jumpButton;
void Update()
{
if (jumpButton.WasPressedThisFrame)
Jump();
}Swipe Gesture Detection
Swipes are ideal for directional inputs in casual games: change lanes, select directions, fling objects. Our Swipe Input Controller detects four-directional and eight-directional swipes with configurable thresholds:
using UnityEngine;
using System;
public class SwipeDetector : MonoBehaviour
{
[SerializeField] private float minSwipeDistance = 50f;
[SerializeField] private float maxSwipeTime = 0.5f;
[SerializeField] private bool detectDiagonals = false;
public event Action<SwipeDirection> OnSwipe;
private Vector2 touchStartPos;
private float touchStartTime;
private bool isSwiping;
void Update()
{
if (Input.touchCount == 0) return;
Touch touch = Input.GetTouch(0);
switch (touch.phase)
{
case TouchPhase.Began:
touchStartPos = touch.position;
touchStartTime = Time.time;
isSwiping = true;
break;
case TouchPhase.Ended:
if (!isSwiping) break;
isSwiping = false;
float elapsed = Time.time - touchStartTime;
if (elapsed > maxSwipeTime) break;
Vector2 delta = touch.position - touchStartPos;
if (delta.magnitude < minSwipeDistance) break;
SwipeDirection dir = GetDirection(delta);
OnSwipe?.Invoke(dir);
break;
case TouchPhase.Canceled:
isSwiping = false;
break;
}
}
private SwipeDirection GetDirection(Vector2 delta)
{
if (detectDiagonals)
{
float angle = Mathf.Atan2(delta.y, delta.x) * Mathf.Rad2Deg;
if (angle < 0) angle += 360f;
if (angle < 22.5f || angle >= 337.5f) return SwipeDirection.Right;
if (angle < 67.5f) return SwipeDirection.UpRight;
if (angle < 112.5f) return SwipeDirection.Up;
if (angle < 157.5f) return SwipeDirection.UpLeft;
if (angle < 202.5f) return SwipeDirection.Left;
if (angle < 247.5f) return SwipeDirection.DownLeft;
if (angle < 292.5f) return SwipeDirection.Down;
return SwipeDirection.DownRight;
}
// 4-directional: pick dominant axis
if (Mathf.Abs(delta.x) > Mathf.Abs(delta.y))
return delta.x > 0 ? SwipeDirection.Right : SwipeDirection.Left;
else
return delta.y > 0 ? SwipeDirection.Up : SwipeDirection.Down;
}
}
public enum SwipeDirection
{
Up, Down, Left, Right,
UpLeft, UpRight, DownLeft, DownRight
}Tune minSwipeDistance for your game's feel — too low and accidental taps register as swipes, too high and deliberate swipes get ignored. A value of 50-80px works for most games on standard phone screens. The maxSwipeTime prevents slow drags from counting as swipes.
Pinch to Zoom Camera
Pinch-to-zoom is essential for any game with a zoomable camera: strategy games, map screens, sandbox builders. Our Pinch to Zoom Camera script handles both orthographic and perspective cameras:
using UnityEngine;
public class PinchToZoom : MonoBehaviour
{
[SerializeField] private Camera targetCamera;
[SerializeField] private float zoomSpeed = 0.01f;
[SerializeField] private float minZoom = 2f;
[SerializeField] private float maxZoom = 12f;
[SerializeField] private float smoothing = 8f;
private float targetZoom;
private float previousPinchDistance;
void Start()
{
if (targetCamera == null) targetCamera = Camera.main;
targetZoom = targetCamera.orthographic
? targetCamera.orthographicSize
: targetCamera.fieldOfView;
}
void Update()
{
if (Input.touchCount == 2)
{
Touch t0 = Input.GetTouch(0);
Touch t1 = Input.GetTouch(1);
float currentDistance = Vector2.Distance(t0.position, t1.position);
if (t0.phase == TouchPhase.Began || t1.phase == TouchPhase.Began)
{
previousPinchDistance = currentDistance;
return;
}
float pinchDelta = previousPinchDistance - currentDistance;
targetZoom += pinchDelta * zoomSpeed;
targetZoom = Mathf.Clamp(targetZoom, minZoom, maxZoom);
previousPinchDistance = currentDistance;
}
// Smooth zoom interpolation
if (targetCamera.orthographic)
{
targetCamera.orthographicSize = Mathf.Lerp(
targetCamera.orthographicSize, targetZoom, Time.deltaTime * smoothing);
}
else
{
targetCamera.fieldOfView = Mathf.Lerp(
targetCamera.fieldOfView, targetZoom, Time.deltaTime * smoothing);
}
}
}The smooth interpolation prevents jarring zoom jumps. Set minZoom and maxZoom to keep the camera within usable bounds — too close and the player can't see enough context, too far and game elements become unreadable.
Safe Area & Responsive Scaling
Modern phones have notches, rounded corners, navigation bars, and dynamic islands that eat into screen space. If your touch controls sit behind a notch, they're unusable. Unity's Screen.safeArea API tells you the usable rectangle.
using UnityEngine;
[RequireComponent(typeof(RectTransform))]
public class SafeAreaFitter : MonoBehaviour
{
private RectTransform rectTransform;
private Rect lastSafeArea;
void Awake()
{
rectTransform = GetComponent<RectTransform>();
ApplySafeArea();
}
void Update()
{
// Recheck on rotation or layout change
if (Screen.safeArea != lastSafeArea)
ApplySafeArea();
}
private void ApplySafeArea()
{
Rect safeArea = Screen.safeArea;
lastSafeArea = safeArea;
// Convert safe area from screen coords to anchor coords
Vector2 anchorMin = safeArea.position;
Vector2 anchorMax = safeArea.position + safeArea.size;
anchorMin.x /= Screen.width;
anchorMin.y /= Screen.height;
anchorMax.x /= Screen.width;
anchorMax.y /= Screen.height;
rectTransform.anchorMin = anchorMin;
rectTransform.anchorMax = anchorMax;
}
}Attach SafeAreaFitter to a parent panel that contains all your touch controls. This automatically adjusts the control boundaries to avoid notches and system UI. All child elements (joystick, buttons) inherit the safe positioning without needing individual adjustments.
For responsive button sizing across different screen densities, set your Canvas Scaler to Scale With Screen Size with a reference resolution of 1080x1920 and a match mode of 0.5 (balanced width/height). This ensures your 80x80px button looks physically the same size on a 5-inch phone and a 10-inch tablet.
Download the Complete Mobile Controls Kit
Every script in this tutorial is available as a free individual download:
- Touch Joystick — Fixed and dynamic virtual joystick with dead zone and visual feedback
- Touch Button — Pressed, held, and released states with haptic feedback option
- Swipe Input Controller — 4-directional and 8-directional swipe detection
- Pinch to Zoom Camera — Orthographic and perspective camera zoom with smooth interpolation
Or grab everything in one package with the Mobile Controls Kit game system bundle. It includes all four scripts plus a prefab-based setup system, a safe area auto-fitter, control visibility toggling, haptic feedback integration, and a demo scene showing all controls in action.
Building a mobile platformer? Combine the mobile controls kit with our 2D Platformer Kit for a complete mobile-ready game foundation. The Player Controller 2D script already supports virtual joystick input — just wire up the reference and you're running.