obOB STUDIO
← Back to blog
Godot

How I Handle Input on Both Touch and Desktop

6 min readolivers-racers

the abstraction I never wrote

When I brought Oliver's Racers to phones I budgeted a weekend for an input layer. Some InputController node, a normalised struct, the car reading intent instead of hardware. Textbook stuff.

I never wrote it. The touch support has been shipping for a long time now and there is still no such class in this project, and I have mostly stopped feeling guilty about it.

What is actually there is five public variables on the car:

# Touch-control axes, written by touch.gd on mobile. Neutral values on desktop
# so the keyboard feel is untouched.
var touch_steer := 0.0      # [-1, 1], exactly 0.0 when no finger steers (= roll on planes)
var touch_gas := false
var touch_brake := false
var touch_drift := false
var touch_boost := false

That is the whole interface. touch.gd writes them, car.gd reads them, and nothing sits in between.

the merge is addition, not arbitration

Here is where I expected the complexity, and where it turned out not to be. The keyboard builds a steering value the boring way, and then touch is simply added on top:

var steer_in := 0.0
if _kp(KEY_D) or _kp(KEY_RIGHT):
    steer_in += 1.0
if _kp(KEY_A) or _kp(KEY_LEFT):
    steer_in -= 1.0
steer_in = clampf(steer_in + touch_steer, -1.0, 1.0)

There is no "does this player have a keyboard" check anywhere, and no priority rule about which device wins. The buttons are the same idea with or:

var gas := _kp(KEY_W) or _kp(KEY_UP) or touch_gas or touch_boost
braking = _kp(KEY_S) or _kp(KEY_DOWN) or touch_brake
var handbrake := _kp(KEY_SHIFT) or _kp(KEY_CTRL) or touch_drift

The reason this works is not cleverness, it is arithmetic. 0.0 is the identity element for addition and false is the identity for or. On desktop, where the touch pad never runs, every touch field sits at its neutral value and these expressions collapse to exactly the keyboard-only code I had before touch existed.

Which means desktop steering did not change when mobile shipped. Not "changed but I retuned it". Did not change, because on desktop the merge is provably a no-op. That is the property an abstraction layer would have had to work to preserve, and that addition hands you for free.

the deadzone snaps to literal zero

There is one place I did have to be fussy, and it is not the place I expected.

const DEADZONE_X := 0.10
...
car.touch_steer = 0.0 if absf(_stick.x) < DEADZONE_X else _stick.x

That a deadzone is needed at all is not the interesting part. A thumb resting on glass never reads zero, it reads 0.004, which is a rounding error to a human and a permanent slow left turn to a car.

The subtler part is why it snaps to exactly 0.0 rather than merely something small. The keyboard's centred state is literally 0.0. If touch idles at 0.004 instead, then steer_in + touch_steer is 0.004 on a phone and 0.0 at a desk, and everything downstream that compares against zero (the steering smoothing, the drift logic, a plane's roll) quietly behaves differently on the two platforms. The comment in the file is blunt about it: exact 0.0 matches the keyboard-centred state.

There is no response curve. I wrote one, could not tell the difference while actually driving, and deleted it.

one joystick drives the whole car

The other thing I would have over-built is the layout. There is a single floating joystick on the left, and it is not only steering:

  • push up past GAS_ON (-0.20) is throttle
  • pull down past BRAKE_ON (0.24) is brake and reverse
  • left and right steer

Boost and drift are held buttons on the right. Fingers are tracked by their touch .index, which is the detail that makes it playable at all: your left thumb can be mid-corner while your right thumb holds boost, and neither steals the other's events.

the bug this shape really does cause

I do not want to oversell the simple version. Flat public fields have one honest failure mode, and I hit it: state that outlives the moment that set it.

Open the garage mid-corner with the throttle held, and touch_gas stays true forever. The finger that set it is now busy with a menu and will never send a release. The car happily drives off on its own behind the pause screen.

## Called by the world when a menu opens, so held inputs don't stick.
func release_controls() -> void:
    touch_steer = 0.0
    touch_gas = false
    touch_brake = false
    touch_drift = false

An input layer that owned its own state could have noticed the pause and cleaned up by itself. With five public fields, the world has to remember to knock. That is a real cost of doing it the simple way, and one explicit function was the price of admission.

planes reuse the same field

touch_steer does double duty. On a plane the same axis is roll:

roll_in = clampf(roll_in + touch_steer, -1.0, 1.0)

Same field, same deadzone, same merge, different vehicle. Had I built the abstraction, this would have been a config flag, an enum, probably a subclass. Instead it is the same line with a different variable on the left.

the takeaway, which is not the one I expected

The advice I would have given before shipping this is "build an input intent layer". The advice I would actually give now is: check whether your neutral values already do the job.

If every input source can be written so that not touching it is the identity element of however you combine sources, you can merge them with + and or and skip the layer entirely. If they cannot (if one device has to suppress another rather than contribute nothing) then you need real arbitration, and you should go and build it properly.

For a car with five inputs, addition was enough. I would not have believed that before I shipped it.

ob

Written by Oliver

I build browser games and simulations on my own, everything here runs in a tab, with no installer and no account. The biggest is Oliver's Racers: procedural circuits in Godot 4, online multiplayer relayed by a Raspberry Pi in my room, and an Android build. Almost nothing here is imported artwork; the cars, trees and grandstands are built out of boxes and cylinders in code at load time.

More about me · See the projects