Part of these game systems:
intermediate Touch & Mobile

Touch Joystick

Virtual on-screen joystick for mobile touch input with dynamic or fixed positioning. Outputs normalized Vector2 direction suitable for character movement.

Unity 2022.3+ · 4.2 KB · TouchJoystick.cs

How to Use

1

Create a Canvas with Screen Space - Overlay render mode

2

Add two UI Image children: one for the joystick background and one for the handle

3

Attach the TouchJoystick script to the background Image

4

Drag the background and handle RectTransforms into the inspector fields

5

Read InputDirection from your movement script to move the player

6

Enable isDynamic for the joystick to appear where the player touches

Features

  • Dynamic or fixed joystick position
  • Configurable handle range
  • Normalized input output (-1 to 1)
  • Visual handle follows touch
  • Auto-hide when not in use

When to Use This

Essential for any mobile game with analog movement — top-down shooters, RPGs, platformers, and action games. Use dynamic mode for casual games where the joystick appears at touch position, or fixed mode for games that need consistent control placement.

Common Mistakes

The Canvas must use Screen Space - Overlay or Screen Space - Camera with the correct camera assigned, or touch positions won't map correctly. The background and handle must be separate UI Image children — nesting them incorrectly breaks the drag calculation. If InputDirection reads zero while dragging, check that an EventSystem exists in the scene.

Source Code

TouchJoystick.cs
C#
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

public class TouchJoystick : MonoBehaviour, IPointerDownHandler, IDragHandler, IPointerUpHandler
{
    [Header("Joystick Settings")]
    [SerializeField] private float handleRange = 50f;
    [SerializeField] private bool isDynamic = true;
    [SerializeField] private bool autoHide = true;

    [Header("References")]
    [SerializeField] private RectTransform background;
    [SerializeField] private RectTransform handle;

    [Header("Visual")]
    [SerializeField] private float activeAlpha = 1f;
    [SerializeField] private float inactiveAlpha = 0.3f;

    public Vector2 InputDirection { get; private set; }
    public float InputMagnitude { get; private set; }
    public bool IsActive { get; private set; }

    private RectTransform baseRect;
    private Canvas canvas;
    private Camera uiCamera;
    private Vector2 fixedPosition;
    private CanvasGroup canvasGroup;

    private void Awake()
    {
        baseRect = GetComponent<RectTransform>();
        canvas = GetComponentInParent<Canvas>();

        if (canvas.renderMode == RenderMode.ScreenSpaceCamera)
            uiCamera = canvas.worldCamera;

        canvasGroup = GetComponent<CanvasGroup>();
        if (canvasGroup == null)
            canvasGroup = gameObject.AddComponent<CanvasGroup>();

        fixedPosition = background.anchoredPosition;

        if (autoHide)
            canvasGroup.alpha = inactiveAlpha;
    }

    public void OnPointerDown(PointerEventData eventData)
    {
        IsActive = true;

        if (autoHide)
            canvasGroup.alpha = activeAlpha;

        if (isDynamic)
        {
            Vector2 localPoint;
            RectTransformUtility.ScreenPointToLocalPointInRectangle(
                baseRect, eventData.position, uiCamera, out localPoint);
            background.anchoredPosition = localPoint;
        }

        OnDrag(eventData);
    }

    public void OnDrag(PointerEventData eventData)
    {
        Vector2 localPoint;
        RectTransformUtility.ScreenPointToLocalPointInRectangle(
            background, eventData.position, uiCamera, out localPoint);

        Vector2 clampedInput = localPoint / (background.sizeDelta * 0.5f);

        InputMagnitude = Mathf.Min(clampedInput.magnitude, 1f);

        if (clampedInput.magnitude > 1f)
            clampedInput = clampedInput.normalized;

        InputDirection = clampedInput;

        Vector2 handlePos = clampedInput * handleRange;
        handle.anchoredPosition = handlePos;
    }

    public void OnPointerUp(PointerEventData eventData)
    {
        IsActive = false;
        InputDirection = Vector2.zero;
        InputMagnitude = 0f;
        handle.anchoredPosition = Vector2.zero;

        if (isDynamic)
            background.anchoredPosition = fixedPosition;

        if (autoHide)
            canvasGroup.alpha = inactiveAlpha;
    }

    public Vector2 GetInput()
    {
        return InputDirection;
    }

    public float GetHorizontal()
    {
        return InputDirection.x;
    }

    public float GetVertical()
    {
        return InputDirection.y;
    }

    public void SetDynamic(bool dynamic)
    {
        isDynamic = dynamic;
    }

    public void SetHandleRange(float range)
    {
        handleRange = Mathf.Max(10f, range);
    }

    private void OnDisable()
    {
        InputDirection = Vector2.zero;
        InputMagnitude = 0f;
        IsActive = false;
        handle.anchoredPosition = Vector2.zero;

        if (canvasGroup != null)
            canvasGroup.alpha = inactiveAlpha;
    }
}
Ready for more? Touch Button Mobile-friendly UI button with press, release, and hold events plus visual feedback.