Drift Physics — Making Cars Feel Right Without a Physics Engine
I spent two weeks with RigidBody3D before I threw it out. Not because it can't simulate a car — it absolutely can. But because it simulates a car too well. I don't want tire slip curves and suspension springs. I want you to hold SPACE, kick the tail out, and slide around a corner grinning. That's a CharacterBody3D job.
The constants that matter
Here are the numbers that define how every car in the game feels:
extends CharacterBody3D
const GRAVITY := 30.0
const DRAG_QUAD := 0.010 # quadratic drag
const DRAG_LIN := 0.22 # rolling resistance
const BRAKE_POWER := 36.0 # decel when braking from forward speed
const STEER_RATE := 1.9 # base yaw rate at full lock (rad/s)
const STEER_SMOOTH := 8.0 # how fast the wheel turns toward the input
const HIGH_SPEED_TAME := 0.52 # steering reduction approaching top speed
const HANDBRAKE_GRIP := 0.22 # grip multiplier while the handbrake is on
const HANDBRAKE_YAW := 1.35 # extra steering kick while drifting
const DRIFT_SIDE_SPD := 4.2 # sideways m/s that counts as a driftThat HANDBRAKE_GRIP is the magic number. Normal grip bleeds sideways velocity fast — your car tracks where it's pointed. Hold the handbrake and that grip drops to 22% of its normal value. Suddenly the back end keeps sliding. That's drifting.
The grip model
The actual grip logic is one line of math buried in the _drive() function:
l2 *= exp(-grip_eff * delta)
flat = fwd * f2 + right * l2l2 is the car's lateral speed — how fast it's moving sideways. grip_eff is the effective grip value, which accounts for the car's base grip stat, whether you're on road or grass, whether the surface is slippery (snow has slick = 0.82), and whether the handbrake is on:
var grip_eff := _grip * slick * (surface if off else 1.0)
if handbrake:
grip_eff *= HANDBRAKE_GRIP
if not grounded:
grip_eff *= 0.06 # airborne: almost no lateral bleedThe exponential decay is key. It means high grip doesn't snap the car straight — it eases it. The tail swings and then catches. At low grip (handbrake held), the decay is slow enough that you maintain a long, sweeping slide. And in the air? Grip drops to 6%. You barely realign at all, which is why jumps feel floaty and momentum-driven.
Speed-sensitive steering
Nobody wants full steering lock at 200 km/h. That's how you get barrel rolls. So the steering authority scales with speed in both directions:
var authority := clampf(absf(f_spd) / STEER_REF_SPEED, 0.0, 1.0)
var tame := 1.0 - HIGH_SPEED_TAME * clampf(absf(f_spd) / _top, 0.0, 1.0)
var yaw := steer * STEER_RATE * _steer_mult * authority * tameAt low speed, authority is low — you can't spin in place. At high speed, tame kicks in and chops up to 52% off your turning rate. The sweet spot is mid-range, where the car is responsive but controllable. And when the handbrake is on, HANDBRAKE_YAW = 1.35 cranks the steering rate up so you can actually aim through a drift.
Six cars, six personalities
Every car reads its stats from a catalog at equip time:
func apply_car() -> void:
var c: Dictionary = CarCatalog.get_car(GameState.equipped)
_power = float(c.power)
_top = float(c.top_speed)
_grip = float(c.grip)
_steer_mult = float(c.steer)
_offroad_stat = float(c.offroad)The go-kart has low top speed but sharp steering and decent grip. The hypercar is blisteringly fast but starts drifting if you even look at a corner wrong. The Rally Lynx has a high _offroad_stat, so it barely slows on grass while other cars bog down to 55% top speed. Vortex GT? Beautiful on asphalt. Touch grass and it's like driving on ice.
The beauty of the exponential grip model is that these stat differences compound. A high-grip car on a slippery surface drifts differently from a low-grip car on tarmac — not just "more" or "less" but in genuinely distinct ways that you learn to feel.
The dead end: RigidBody
Here's what went wrong with RigidBody3D. Godot's physics engine applies forces, torques, and impulses, then resolves collisions. But I need frame-perfect control over the velocity vector. With CharacterBody3D, I set velocity directly and call move_and_slide(). With RigidBody, I'd have to fight the solver — applying counter-forces to stop unwanted rotation, manually clamping angular velocity, and praying the integrator doesn't introduce weird oscillations at high speed.
Could I have made it work? Sure. But arcade driving isn't about simulation accuracy. It's about feel. And feel means I need to touch every number, every frame, with zero surprises from a physics solver I don't own.
CharacterBody3D gave me that control. And the code ended up simpler, too.
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.