Touch Input and Mobile UX

Module 2 ยท Lesson 2 ยท ~30 min ยท Godot 4.x

Godot treats mouse and touch somewhat interchangeably, but "somewhat" is the word that bites you. This lesson gets you to working touch controls on an Android device, and calls out the gotchas that cost people weeks.

Input types, briefly

Event classFires on
InputEventMouseButtonClick/release on desktop. Also on Android if emulation is on.
InputEventMouseMotionMouse move. Also finger drag when emulation is on.
InputEventScreenTouchFinger down/up. Android/iOS native. Has index for multi-touch.
InputEventScreenDragFinger drag. Has index, position, relative.
InputEventKeyKeyboard.
InputEventActionAbstracted action mapped in Input Map. Prefer this.
The setting that saves you Project Settings โ†’ General โ†’ Input Devices โ†’ Pointing โ†’ Emulate Mouse From Touch: On and Emulate Touch From Mouse: On. Both default to on for new projects. Leave them on. They let you test touch flows with your desktop mouse and mouse flows on your phone, cutting your dev cycle in half.

Three ways to handle input

For a Card scene with a touch target, you have three handlers. Know when to use each.

# 1. Control.gui_input โ€” fires when input hits a Control node's rect.
# This is the cleanest option for UI. Fires only for events *on* this control.
func _gui_input(event: InputEvent) -> void:
    if event is InputEventMouseButton and event.pressed:
        _on_card_tapped()

# 2. Area2D signals โ€” fires for physics-based touch targets in Node2D-space.
# Use for game-world entities that aren't Controls.
func _ready() -> void:
    $TouchArea.input_event.connect(_on_touch)

func _on_touch(_viewport, event: InputEvent, _shape_idx: int) -> void:
    if event is InputEventMouseButton and event.pressed:
        _on_card_tapped()

# 3. Node._input โ€” global handler, fires for every input event in the tree.
# Use sparingly โ€” for global hotkeys, pause menus, debug shortcuts.
func _input(event: InputEvent) -> void:
    if event.is_action_pressed("pause"):
        pause_game()

Input actions: the right abstraction

Don't hard-code KEY_SPACE in your game logic. Map it to an action.

Project Settings โ†’ Input Map โ†’ add action end_turn. Bind it to Space, or a touch zone, or a controller button. In code:

func _input(event: InputEvent) -> void:
    if event.is_action_pressed("end_turn"):
        turn_manager.advance()

# Also useful:
if Input.is_action_pressed("move_right"):  # continuous
if Input.is_action_just_pressed("jump"):   # one-frame trigger

For a word/card game you won't have many actions โ€” maybe end_turn, submit_word, clear_selection. Map them, keep your game logic device-agnostic.

Tap, drag, gesture

Touch is a stream of events. To recognize "tap vs drag" you track state.

extends Control

var drag_start: Vector2
var dragging := false
var DRAG_THRESHOLD := 10.0

func _gui_input(event: InputEvent) -> void:
    if event is InputEventMouseButton:
        if event.pressed:
            drag_start = event.position
            dragging = false
        else:
            if not dragging:
                _on_tap()
            else:
                _on_drag_end(event.position)
    elif event is InputEventMouseMotion:
        if event.button_mask & MOUSE_BUTTON_LEFT:
            if event.position.distance_to(drag_start) > DRAG_THRESHOLD:
                dragging = true
                _on_drag(event.position)

func _on_tap() -> void: print("tap")
func _on_drag(pos: Vector2) -> void: print("dragging at ", pos)
func _on_drag_end(pos: Vector2) -> void: print("drag end at ", pos)

With "Emulate Touch From Mouse" on, this same code handles both desktop and phone.

Drag-and-drop in Control nodes

Godot has built-in drag-drop on Control nodes. Override three methods:

func _get_drag_data(_pos: Vector2) -> Variant:
    # Return data to carry. Return null to cancel.
    # Also set the drag preview here.
    set_drag_preview(_make_preview())
    return { "card": self, "letter": data.letter }

func _can_drop_data(_pos: Vector2, data: Variant) -> bool:
    return data is Dictionary and data.has("card")

func _drop_data(_pos: Vector2, data: Variant) -> void:
    var card: Card = data.card
    accept_card(card)

func _make_preview() -> Control:
    var ghost := TextureRect.new()
    # configure ghost to look like the card
    return ghost

This pattern handles all the event plumbing for you โ€” including multi-touch and cross-control drops. For Lexicon Duel, this is how cards will drag from the hand into a "word being built" slot.

Minimum tap target

Apple's Human Interface Guidelines and Google's Material guidelines both recommend ~44pt minimum tap targets. On a 1080x1920 reference viewport that's at least 88px. Cards should be 140+px wide to tap comfortably. Buttons 88ร—88px minimum.

Common mistake Making UI elements based on how they look on your 4K monitor. On a phone your thumb is wider than your UI. Always test on the actual device early and often.

Haptic feedback

Tap something important? Buzz the phone. Godot exposes a cross-platform haptic call:

Input.vibrate_handheld(40)  # 40ms buzz on mobile; no-op on desktop

Use sparingly โ€” every tap is annoying; a tap that finishes a word is delightful.

Remote testing on Android

Once you install the Android export template (Module 7 โ€” don't do this yet), Godot can push a live build over USB. Flow:

  1. Enable USB debugging on your Android phone.
  2. Plug in via USB.
  3. In Godot: click the Android icon next to the play button. It builds and pushes.
  4. Runs on your phone. Output logs stream back to the editor.

We'll do this end-to-end in Module 7. Right now, just know it exists and is fast.

Do this now

  1. Open scenes/card.tscn from Module 1. If you made it a Node2D with an Area2D, you have option 2 input. Keep it.
  2. Add a CanvasLayer to your main scene, with a Control (Full Rect), containing a Button in the top right anchored Top Right, labeled "End Turn".
  3. Connect its pressed signal to a method that prints "turn ended".
  4. Run. Click the button. You just built your first UI.