A Godot game that plays well with a keyboard and a gamepad usually goes to phones last. The port then turns into a second game: a joystick script that moves the player directly, buttons that call jump() themselves, a tutorial that still says "Press Space", and a phone where the jump button sits under the notch. This guide shows a different route. The on-screen controls press the same input actions the keyboard and the gamepad press, so the game code does not change, and the prompts follow whatever the player is holding. Every GDScript sample here was run in Godot 4.7.1.
#Press actions, do not call game code
Godot's Input Map is the seam to use. Your character already reads Input.get_vector() or Input.is_action_pressed(&"jump"); a key, a stick and a trigger are just different events bound to those actions. A touch control should be one more source of the same actions. Input.action_press() takes a strength between 0 and 1, so a virtual joystick can press move_right at 0.53 exactly as a real stick would:
extends Node
const DEADZONE := 0.15
func stick_to_actions(raw: Vector2) -> void:
var v := Vector2.ZERO
if raw.length() > DEADZONE:
var t := clampf((raw.length() - DEADZONE) / (1.0 - DEADZONE), 0.0, 1.0)
v = raw.normalized() * t
_set_action(&"move_left", maxf(-v.x, 0.0))
_set_action(&"move_right", maxf(v.x, 0.0))
_set_action(&"move_up", maxf(-v.y, 0.0))
_set_action(&"move_down", maxf(v.y, 0.0))
func _set_action(action: StringName, strength: float) -> void:
if strength > 0.0:
Input.action_press(action, strength)
else:
Input.action_release(action)
raw is the knob's offset from the centre divided by the joystick's radius, so its length runs from 0 to 1. Call it on every drag and once with Vector2.ZERO when the finger lifts. Because the actions are released when the strength drops to zero, nothing stays pressed after the finger is gone.
#The double deadzone
Here is the surprise we hit when we ran it. Every action has its own deadzone, and actions created in code or in the Input Map start at 0.2. Input.get_vector() uses the average deadzone of its four actions and rescales what is above it. Our joystick already removed its own deadzone, so the two stack:
- a push of 0.3 gives a strength of 0.18, and
get_vector()turns it into nothing at all; - a push of 0.6 gives 0.53, and
get_vector()returns 0.41; - with
Input.get_vector(&"move_left", &"move_right", &"move_up", &"move_down", 0.0), or with the actions' deadzone set to 0, the character gets 0.18 and 0.53.
The player feels it as a joystick that does nothing for the first third of its travel. Keep one deadzone, in one place. If you want the Input Map's deadzone for real sticks, let the touch joystick send raw values; if the joystick has its own, pass a zero deadzone where you read the vector.
#Buttons for several fingers
On a phone the left thumb holds the joystick while the right thumb presses jump. Godot's Button and other Controls react to emulated mouse events, and there is only one mouse, so two fingers on two Controls fight. Touch events carry an index per finger. A button that remembers which finger pressed it works next to the joystick:
extends Control
@export var action: StringName = &"jump"
var finger := -1
func _input(event: InputEvent) -> void:
if not event is InputEventScreenTouch:
return
if event.pressed and finger < 0 and get_global_rect().has_point(event.position):
finger = event.index
Input.action_press(action)
get_viewport().set_input_as_handled()
elif not event.pressed and event.index == finger:
release_all()
func release_all() -> void:
if finger >= 0:
finger = -1
Input.action_release(action)
func _notification(what: int) -> void:
if what in [NOTIFICATION_PAUSED, NOTIFICATION_EXIT_TREE, NOTIFICATION_APPLICATION_FOCUS_OUT]:
release_all()
elif what == NOTIFICATION_VISIBILITY_CHANGED and not is_visible_in_tree():
release_all()
In our run, lifting another finger left jump pressed, and hiding the button or pausing the game released it. The second part matters more than it looks. A pause menu that opens while a finger is on a button, or a phone call that takes the app to the background, otherwise leaves the action held: the player comes back and the character runs into a wall by itself.
Godot also has a built-in TouchScreenButton. It is a Node2D, not a Control, so it does not follow containers or anchors, but it presses an action, tracks its own finger and can show itself only on touch screens. For one or two buttons in a simple layout it is a good start.
#Know what the player is holding
Touch controls should be visible for touch players only, and prompts should name the right key. Both need the device the player used last. An autoload that watches every event can tell, as long as it ignores two kinds of noise:
extends Node
## Autoload "Device": the kind of device the player used last.
signal device_changed(kind: String)
var kind := "keyboard"
func _input(event: InputEvent) -> void:
var next := kind
if event is InputEventScreenTouch or event is InputEventScreenDrag:
next = "touch"
elif event is InputEventJoypadButton and event.pressed:
next = "gamepad"
elif event is InputEventJoypadMotion and absf(event.axis_value) >= 0.5:
next = "gamepad"
elif event is InputEventKey and event.pressed:
next = "keyboard"
elif event is InputEventMouseButton and event.pressed \
and event.device != InputEvent.DEVICE_ID_EMULATION:
next = "keyboard"
if next != kind:
kind = next
device_changed.emit(kind)
The first kind of noise is stick drift: a worn controller lying on the desk sends small axis values all the time, and without the 0.5 threshold your keyboard prompts would flicker to gamepad prompts. In our run a drift of 0.12 changed nothing. The second is Godot's own mouse emulation. "Emulate Mouse From Touch" is on by default, so every tap also arrives as a mouse click with the device id InputEvent.DEVICE_ID_EMULATION. Without that check, every tap would switch a phone player back to "keyboard" and hide the controls they just touched.
To try touch controls on a desktop, turn on "Emulate Touch From Mouse" in Project Settings, Input Devices, Pointing. Then a mouse drag becomes a touch drag. It is a useful check, but it is still one finger; the multi-finger cases need a real device or touches sent as input events in a test.
#Prompts that match the controller
"Press A to jump" is right on an Xbox pad and wrong on a Nintendo one, where the bottom face button is labelled B. Godot reports buttons by position (JOY_BUTTON_A is the bottom one on every pad), and the controller's name tells you the family:
extends RefCounted
static func family_of(joy_name: String) -> String:
var n := joy_name.to_lower()
for word in ["xbox", "xinput"]:
if n.contains(word):
return "xbox"
for word in ["playstation", "dualsense", "dualshock", "ps4", "ps5"]:
if n.contains(word):
return "playstation"
for word in ["nintendo", "pro controller", "joy-con"]:
if n.contains(word):
return "nintendo"
return "generic"
static func face_label(button: JoyButton, family: String) -> String:
var labels := {
"nintendo": ["B", "A", "Y", "X"],
"playstation": ["south", "east", "west", "north"],
}
return labels.get(family, ["A", "B", "X", "Y"])[button]
Pass it Input.get_joy_name(event.device). For "Nintendo Switch Pro Controller" the bottom button comes out as B; for "DualSense Wireless Controller" it is "south". PlayStation's face symbols are trademarks, so many games show those buttons by position or with a neutral icon; that is what the sample's position names stand for. An unknown pad falls back to A B X Y, which is what most third-party controllers print.
On a phone there is no key to name, so the text itself should change: "Drag the stick to move" instead of "Press WASD to move". Keep a separate touch version of each tutorial line instead of trying to squeeze a glyph into it.
#Notches and rounded corners
A button in the bottom right corner of a modern phone can sit under a rounded edge or the gesture bar. DisplayServer.get_display_safe_area() returns the part of the screen the system leaves free. Put the touch controls in a full-screen container whose margins are that safe area plus a comfortable minimum, and update the margins when the window size changes, since rotating the phone moves the notch.
#Add-ons that do this
We packed this approach into Pipalo, a Godot 4 add-on: a fixed, floating or following joystick that presses your actions with analog strength, buttons for several fingers with slide-on, gestures that ignore taps your buttons already used, a safe area, device tracking with controller families, and prompts that switch between keyboard, gamepad and touch. It was tested with 19 headless tests in Godot 4.7.1 on Linux and with simulated touches, not yet on a real phone or with real gamepads. The glyphs themselves are in Podkanka, 161 input prompt images for keyboard, mouse, gamepads and touch in three styles, with a Godot helper that picks the glyph for an action and a device. Both are listed as Coming soon.
#Frequently asked questions
Should the joystick move the character directly?
No. Press the same actions the keyboard and gamepad press, so the movement code, the deadzone and the key rebinding stay in one place and the touch version cannot drift from the others.
Why does my virtual joystick feel dead near the centre?
Usually because two deadzones stack: the joystick's own and the actions' 0.2 that Input.get_vector() applies. Keep one of them.
Why do the touch controls hide right after I tap them?
Godot turns each tap into an emulated mouse click too. If your device check treats every mouse click as "keyboard", it switches away from touch. Ignore events whose device is InputEvent.DEVICE_ID_EMULATION.
Can I test touch controls without a phone?
Partly. "Emulate Touch From Mouse" gives you one finger on the desktop; for two fingers, send InputEventScreenTouch events with different indexes from a test, and try a real device before release.
Comments
Comments
Be the first to leave a comment.
Leave a comment