obOB STUDIO
Physics

Arcade Flight Physics — Lift, Drag, and Keeping It Fun

6 min readolivers-planes

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 dies

Every 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) * delta

Lift 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) * delta

Drag 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 dies

You 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_FLOOR

Coordinated 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.

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.