Skip to content

Peti Virage: tyre physics, from Bevy to Godot

SUKIYAKI
Updated date:

The pitch

A racing game that isn’t after graphical realism, but physical realism. Not one more arcade game where you hold the drift on the handbrake. Here the tyres have real behaviour, and driving takes finesse. I like cars, I like simulation, and the idea was to see how far I could push a driving model on my own, with no commercial game engine behind me.

The PSX look (wobbling textures, vertex snapping, low resolution) is the art direction I’ve had in mind from the start. None of it is implemented, and I may as well say so up front. What the game looks like now is whatever Bevy, and then Godot, gives you by default. All the time went into the tyres.

The physics, first

The starting stack was Rust, Bevy 0.18 for the ECS and rendering, avian3d 0.5 for the rigid body. The vehicle simulation runs in a FixedUpdate at 240 Hz. That’s expensive, but below it the tyre forces go unstable and the car starts oscillating on its own.

The tyre model is a Pacejka PAC2002 (Magic Formula 5.2). The two fundamental inputs are the slip ratio (κ, longitudinal slip) and the slip angle (α, lateral slip). Out of the formula come a longitudinal force, a lateral force and a self-aligning torque. Combined slip goes through the PAC2002 G functions. Getting on the throttle mid-corner eats into lateral grip, and so does braking in a curve. That’s what makes you feel the limit instead of going straight through it.

Around that, everything that keeps a tyre from being a constant:

The first three combine in the dumbest way possible, by multiplication:

/// Combined grip scale = thermal × wear × pressure.
pub fn grip_scale(temp_c: f32, wear: f32, pressure_bar: f32, optimal_bar: f32, optimal_temp_c: f32) -> f32 {
    thermal_grip_scale(temp_c, optimal_temp_c)
        * (1.0 - 0.3 * wear.clamp(0.0, 1.0))
        * pressure_grip_scale(pressure_bar, optimal_bar)
}

The rest of the car follows: double-wishbone suspension with camber and toe gain, spring plus bump stop plus damper, a driveline from engine to clutch to gearbox to differential, turbo, ABS. There’s force-feedback wheel support too, DirectInput on Windows and evdev on Linux, with exponential smoothing on the force to take out the shaking without killing the feel of the road.

38 commits between the end of February and mid-April. By that point, the car genuinely drove.

Moving to Godot

On 13 April, I ported the whole thing to Godot 4.6 + C#.

The reason has nothing to do with the physics. I wanted a visual editor. In Bevy, every scene, every bit of UI, every node placement is code. That’s fine while you’re writing simulation, but the moment you have to assemble a track, place cameras and wire up a HUD, you spend your time recompiling to move a label. Godot puts the scene at the centre. The editor becomes the source of truth, and C# keeps nothing but the runtime logic.

Rewriting the physics in C#, though, was out of the question. It stayed in Rust, compiled as a GDExtension (rust_physics/) that exposes two classes to the engine, PhysicsSim for one car’s simulation and WheelDevice for the wheel and its force feedback. The contract is deliberately dumb. An array of floats one way, an array back the other, called on every physics tick from CarController._IntegrateForces:

#[func]
fn step(&mut self, inputs: PackedFloat32Array, dt: f32) -> PackedFloat32Array {
    if inputs.len() < 54 {
        return PackedFloat32Array::new();
    }

    let inp = decode_inputs(&inputs, self.auto_clutch);
    let outputs = sim::step(&mut self.state, &inp, dt, &self.config);
    encode_outputs(&outputs)
}

The tire/, suspension/, powertrain/ and brakes/ folders moved from one repo to the other almost untouched. The Magic Formula, the three thermal zones, the ideal gas law and the relaxation filter are all the same code. Only the harness around it changed, from Bevy components to a PhysicsSim that Godot instantiates.

79 commits in six days, 13 to 18 April. Car configs as Godot resources, a dev console, an audio rewrite, menus, telemetry overlays. I went off on a tangent along the way too, with volumetric cloud shadows and a compute shader that ray-marches through the SunshineClouds2 cloud volume to produce a transmittance map projected onto the ground.

The Bevy repo, for its part, is frozen, and that’s deliberate. It’s still the reference for the maths, and its notes file spells that out, “frozen, source-of-truth for physics math”. When a behaviour diverges on the Godot side, I compare against the Bevy version and the Bevy version is the one that’s right. It isn’t a project abandoned in favour of another one. It’s the spec.

Where it stands

Nowhere since April. Not a single commit on either side.

The reason is specific, and it isn’t lack of time. Going further means modelling. The cars and tracks I test on aren’t mine. And once the physics is in place, a racing game is essentially 3D content: bodies, wheels, interiors, scenery, a coherent layout with its kerbs and run-off areas. The PSX look doesn’t save me anything on that front. A low-resolution model that actually looks good is one more constraint, not one less.

3D modelling is something I can’t do yet. It’s a skill in its own right, it takes months of work, and I don’t want to botch the one part missing from a project I like the rest of.

So it’s on hold, with no date. The physics stands up and it’s there in black and white in two repos. The day I can turn out a decent car and a decent stretch of track, there’ll be something to put them in.

Previous
My homelab: self-hosted infrastructure, from Arch Linux to NixOS
Next
SCP: Containment Breach in Rust, reverse-engineering the asset formats