obOB STUDIO
Design

Touch Controls That Don't Suck — Designing for Phone and Desktop

5 min readolivers-racers

Touch controls for a racing game on a phone. The phrase alone makes most people wince. And honestly, my first version deserved that reaction. Two separate joysticks. Gas and brake as invisible zones on the right side. A boost button that overlapped the minimap. It was chaos.

The version that shipped is clean: one stick, two buttons, and a critical project setting that took me three days to debug.

One stick to rule them all

## On-screen touch controls for phones. Drawn entirely in code:
##   * ONE floating joystick on the left does all the driving —
##     push UP = gas, pull DOWN = brake/reverse, LEFT/RIGHT = steer
##   * BOOST + DRIFT buttons on the right (hold), R (rescue)
##   * GARAGE / MAPS / TRACK buttons in a row near the top

The left half of the screen is a steering zone. Touch anywhere in it and a joystick appears under your thumb. Push up for gas, pull down for brake/reverse, tilt left and right for steering. The joystick is floating — it spawns wherever you first touch, so you don't have to hit a specific spot.

Thresholds are tuned separately for each axis:

const DEADZONE_X := 0.10
const GAS_ON := -0.20      # stick y past this = throttle
const BRAKE_ON := 0.24      # stick y past this = brake / reverse

The gas threshold is smaller than the brake threshold. That's intentional. You want gas to engage early — slight push up and you're moving. Braking needs more intention because accidentally braking while trying to steer is infuriating.

Two right-hand buttons

BOOST and DRIFT sit on the right side as hold-to-activate circles. Your right thumb rests on one or the other. Both actions work on hold, not toggle, which matches the desktop keyboard (hold SPACE to drift, hold SHIFT to boost).

func _drift_center(sz: Vector2) -> Vector2:
    return Vector2(sz.x * 0.865, sz.y * 0.72)

func _boost_center(sz: Vector2) -> Vector2:
    return Vector2(sz.x * 0.70, sz.y * 0.60)

Everything is positioned as a fraction of the viewport. Landscape, portrait, wide tablet, narrow phone — it just works. No anchored pixel positions.

The emulate_mouse disaster

Here's the bug that cost me three days. On desktop, the game plays with keyboard + mouse. On mobile, it uses touch. Godot has two project settings that try to bridge these:

  • emulate_mouse_from_touch — makes touch events also generate mouse events
  • emulate_touch_from_mouse — makes mouse clicks also generate touch events

Both default to true. And both completely broke my control scheme.

With emulate_mouse_from_touch on, a touch on the joystick zone generates a mouse event, which my UI buttons (garage, map select) interpret as a click. Phantom button presses. Menus opening mid-race. Nightmare.

With emulate_touch_from_mouse on, a desktop mouse click generates a touch event, which my joystick interprets as a finger. Now the mouse is fighting the keyboard for control of the car. Double inputs everywhere.

The fix is two lines in project.godot:

pointing/emulate_mouse_from_touch=false
pointing/emulate_touch_from_mouse=false

Both off. Each input system stays in its lane. Touch events go to touch.gd. Mouse/keyboard events go to the standard Input actions. Zero crosstalk.

If you're building a Godot game that needs to work on both desktop and mobile: disable both emulations. Handle each input path explicitly. You'll save yourself days.

Auto-hiding on desktop

The touch overlay checks DisplayServer.is_touchscreen_available() at startup and only shows itself on actual touchscreens:

visible = DisplayServer.is_touchscreen_available() or OS.has_environment("FORCE_TOUCH_UI")

On desktop: invisible. No floating joystick, no buttons. Clean screen.

There's one catch: is_touchscreen_available() isn't reliable in web exports. Some browsers lie about it until the first actual touch. So I also listen for the first real touch event and show the controls then:

if event is InputEventScreenTouch and event.pressed:
    visible = true

Comparison: car vs. plane

I ship two games — Oliver's Racers and Oliver's Planes. Both have touch controls. Both use the same "floating stick on the left" pattern. But the details are different.

The car stick maps to discrete states: gas ON, brake ON, steer left/right. The plane stick maps to analog axes: pitch and roll as continuous [-1, 1] values with a deadzone. The plane also has a throttle rail — a vertical slider on the right side that's set-and-forget, like a real throttle lever.

Same philosophy, different implementations. The key insight: on touch, you can't have more than two points of input (two thumbs). So everything has to fit into "left thumb does movement, right thumb does actions." Any control scheme that needs three simultaneous inputs is broken on mobile.

Design for two thumbs. Everything else follows.

ob

Written by Oliver

Indie game developer building 3D web experiences in Godot and Next.js. I write about creative coding, procedural generation, and game feel.