Arcade Flight Physics — Lift, Drag, and Keeping It Fun
Real flight physics are boring. I don't mean that as a knock on real aviation — I mean that if you simulate a Cessna accurately, most players will crash in five seconds, get frustrated, and leave. I know this because that's exactly what happened with my first prototype.
So I threw out accuracy and chased fun. Here's the flight model that survived.
The constants
const GRAVITY := 16.0
const BASE_LIFT := 0.34 # lift accel per unit of forward airspeed
const DRAG_QUAD := 0.0075 # quadratic drag
const DRAG_LIN := 0.05 # linear drag (low-speed stability)
const GRIP := 2.6 # how fast momentum realigns to the nose
const STALL_SPEED := 9.0 # stall warning threshold
const GROUND_ASSIST_H := 14.0 # auto pull-up below this altitude
const ENGINE_OFF_FLOOR := 0.30 # minimum throttle — engine never diesEvery one of these numbers was arrived at by playing the game, crashing, tweaking, and playing again. Over and over. GRAVITY = 16.0 — not 9.8 — because real gravity makes a small-scale flight sim feel sluggish. Everything happens faster and more dramatically at 16 m/s².
Speed-dependent lift
var lift_speed := maxf(fwd_speed, STALL_SPEED * 0.65)
velocity += up * (lift_speed * _lift) * deltaLift is proportional to forward airspeed. Faster = more lift. Slow down too much and gravity wins. But here's the cheat: lift_speed never drops below 65% of stall speed. In a real plane, slow = death spiral. In my game, slow = mushy controls and gentle descent. You don't plummet — you sink. That gives the player time to push the throttle forward and recover.
This "soft stall" was the single biggest playability improvement. Before I added it, half my playtesters hit stall speed, panicked, pulled up (making it worse), and cratered. Now they hit stall, feel the mushiness, and instinctively push the throttle. Much better.
Quadratic drag
var drag_dir := -velocity / speed
velocity += drag_dir * (DRAG_QUAD * speed * speed + DRAG_LIN * speed) * deltaDrag has two components. The quadratic term (0.0075 * v²) dominates at high speed — it's what creates a top speed even at full throttle. The linear term (0.05 * v) matters at low speed and provides stability. Without linear drag, a hovering plane would drift forever in whatever direction the wind last pushed it.
Momentum grip
This is the secret sauce. In real flight, the airflow over the wings only generates lift and induced drag — it doesn't magically redirect your momentum vector. In my game, it does:
# Grip: bleed sideways momentum so the flight path follows the nose
var spd := velocity.length()
if spd > 0.01:
velocity = velocity.lerp(forward * spd, clampf(GRIP * delta, 0.0, 1.0))Every frame, I lerp the velocity vector toward the nose direction at rate GRIP = 2.6. This means that when you bank and pull, the flight path follows. No slipping, no skidding (unless you're barely moving). It makes banking feel like steering a car, which is exactly the arcade feel I want.
Without grip, banking would change where the nose points but not where you're actually going. You'd side-slip through every turn. Realistic? Maybe. Fun? No.
Ground-proximity assist
var agl := global_position.y
if agl < GROUND_ASSIST_H and velocity.y < 0.0:
var u := clampf((GROUND_ASSIST_H - agl) / GROUND_ASSIST_H, 0.0, 1.0)
velocity.y = lerpf(velocity.y, 4.0, clampf(u * 6.0 * delta, 0.0, 1.0))
rotate_object_local(Vector3.RIGHT, u * 2.2 * delta)Below 14 metres altitude and sinking? The game gently pulls your nose up and arrests your descent. The lower you are, the stronger the effect. At 1 metre, it's fighting hard to keep you alive. At 13 metres, it's a gentle nudge.
This is invisible to good players — they're never that low while sinking. But for beginners who don't understand pitch control yet, it's a safety net. They fly low, the ground assist catches them, and they think "wow, I'm really good at this." Which is exactly the lie I want them to believe until they actually are good at it.
The throttle floor
const ENGINE_OFF_FLOOR := 0.30 # minimum throttle so the engine never diesYou can't cut the throttle below 30%. The engine is always running. In a simulator, idle throttle is critical for energy management. In an arcade game, idle throttle means you forgot to press W and now you're in a stall you can't recover from.
The starter plane is even more forgiving — its floor is 35%:
_throttle_floor = 0.35 if GameState.equipped == EngineCatalog.STARTER else ENGINE_OFF_FLOORCoordinated turns
When you bank, the nose yaws toward the low wing automatically:
var bank := atan2(b.x.y, b.y.y)
rotate(Vector3.UP, bank * COORD_TURN * delta)COORD_TURN = 1.45. In a real plane, this is what the rudder does — you coordinate the turn to prevent skidding. In my game, it happens automatically. Bank left, nose swings left. No rudder required (though you can use Q/E for fine trim). This makes the plane feel responsive and predictable, which is the whole goal.
Auto-level on release
When no input is being given — no keyboard, no mouse hold, no touch — the plane eases back to wings-level and nose-to-horizon:
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, LEVEL_PITCH = 1.8. Roll levels out faster than pitch, because an unbanked plane with a slight climb is fine, but a banked plane spirals.
Every cheat in this flight model exists for the same reason: to keep the player flying. Not to teach them aerodynamics. Just to keep them in the air, having fun, long enough to want to get better.
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.