Touch Joystick
Virtual on-screen joystick for mobile touch input with dynamic or fixed positioning. Outputs normalized Vector2 direction suitable for character movement.
How to Use
Create a Canvas with Screen Space - Overlay render mode
Add two UI Image children: one for the joystick background and one for the handle
Attach the TouchJoystick script to the background Image
Drag the background and handle RectTransforms into the inspector fields
Read InputDirection from your movement script to move the player
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
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;
}
}