Mouse Steering in a Flight Sim — The UX Problem Nobody Talks About
"Just use a joystick" isn't good enough
The first version of Oliver's Planes mapped mouse movement directly to pitch and roll. It was horrible. The plane jerked around like a caffeinated hummingbird, and the moment you let go of the mouse the plane held whatever insane bank angle you'd left it in. My testers crashed within seconds. Every single one.
The fix took three iterations and a bunch of magic numbers. Let me walk through them.
Hold-to-steer, release-to-level
The core idea: hold a mouse button to steer, release to fly hands-off. A boolean called _steering tracks whether the mouse is held:
func _input(event: InputEvent) -> void:
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_LEFT or event.button_index == MOUSE_BUTTON_RIGHT:
_steering = event.pressed
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if event.pressed else Input.MOUSE_MODE_VISIBLE
elif event is InputEventMouseMotion and _steering:
_m_roll += event.relative.x
_m_pitch += inv * event.relative.yWhen you hold down left or right click, the cursor locks (captured mode) and mouse motion accumulates into _m_pitch and _m_roll. Release, and the cursor reappears. Simple concept, but getting it to feel right took a lot of tuning.
The magic numbers
These five constants define how mouse steering feels:
const MOUSE_PITCH_SENS := 0.0022 # radians per pixel
const MOUSE_ROLL_SENS := 0.0040
const COORD_TURN := 1.45 # bank-to-turn assist
const LEVEL_ROLL := 3.6 # hands-off return speed
const LEVEL_PITCH := 1.8
const CONTROL_SMOOTH := 9.0 # easing rateMOUSE_PITCH_SENS and MOUSE_ROLL_SENS convert raw pixel movement into radians. Roll sensitivity is almost double pitch — that felt natural because you naturally move the mouse more horizontally than vertically. I tried equal values first and rolling felt sluggish while pitching felt twitchy.
COORD_TURN is the coordinated-turn assist. When the plane is banked, the nose automatically yaws toward the low wing:
var bank := atan2(b.x.y, b.y.y)
rotate(Vector3.UP, bank * COORD_TURN * delta)Without this, you'd bank the plane and it would just… slide sideways. Real planes do this naturally (adverse yaw + lift vector tilt), but in a sim you have to fake it. At 1.45, banking 45° gives a smooth sweeping turn. Lower values made turns feel floaty. Higher values snapped the nose around too aggressively.
Auto-leveling
When _steering is false and no keyboard roll input is active, the plane eases back to wings-level:
if not _steering:
if roll == 0.0:
rotate_object_local(Vector3.BACK, -bank * LEVEL_ROLL * delta)
if pitch == 0.0:
var pitch_ang := asin(clampf((-b.z).y, -1.0, 1.0))
rotate_object_local(Vector3.RIGHT, -pitch_ang * LEVEL_PITCH * delta)LEVEL_ROLL = 3.6 levels the wings pretty quickly. LEVEL_PITCH = 1.8 returns to horizon more gently — if pitch snapped back as fast as roll, it felt like hitting a wall in the air. The asymmetry is subtle but it makes hands-off flight feel smooth instead of robotic.
Smoothing: the secret ingredient
Raw mouse input produces awful frame-to-frame spikes. One frame you move 40 pixels, next frame 2 pixels. The solution is exponential smoothing on the angular rates:
var s := 1.0 - exp(-CONTROL_SMOOTH * delta)
_rate_pitch = lerpf(_rate_pitch, tgt_pitch, s)
_rate_roll = lerpf(_rate_roll, tgt_roll, s)
_rate_yaw = lerpf(_rate_yaw, tgt_yaw, s)CONTROL_SMOOTH = 9.0 means the actual rotation rates chase the target rates with about a 110ms time constant. That's fast enough to feel responsive but slow enough to iron out the jagged peaks. This single addition transformed the feel from "broken flash game" to "this is actually pleasant."
The dead end: acceleration curves
Before I landed on exponential smoothing, I tried mapping mouse speed through a quadratic acceleration curve — small movements stay small, big flicks get amplified. It sort of worked for roll but was a disaster for pitch. Pulling up in a gentle arc was nearly impossible because the curve amplified any tiny hand tremor. I spent two days on it before scrapping the whole thing and trying the lerp approach, which worked on the first attempt. Lesson: sometimes the boring solution is the right one.
Why not just use keyboard?
You can! WASD/arrows work fine. But the mouse gives analog precision — you can do a gentle 5° bank that keyboard input can't express. The two systems blend together naturally because both feed into the same target rates. Touch controls on mobile do the same thing through touch_roll and touch_pitch variables that add into the keyboard axes.
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.