Back to blog
·12 min

More FPS, more speed: GTA V's frame-rate-dependent car physics

In GTA V the same car hits a higher top speed at higher frame rates. An Adder does about 185 km/h at 30 FPS and 227 at 350 on a flat straight road, same car, same input. Turns out the tyre model integrates on the render clock, so how often it runs (and the car's top speed) rides your FPS. Here's how I traced it and fixed it.

Reverse EngineeringGame PhysicsGTA VFiveM

Two players in the same car on the same road. The one at 300 FPS slowly pulls away from the one at 30. The inputs are identical, so the only thing left is the physics itself, and it runs faster the more frames you draw.

On a racing server that means the GPU decides the race, which is obviously not great. This is how I tracked it down: the tyre model integrates on your render clock, so it runs more often the more frames you draw, and that keeps the car closer to peak grip. And then a fix that pins the wheel physics to a fixed rate so everyone gets the same car.

What you see

You don't even need a bumpy road for this. Take a flat, straight stretch, hold full throttle, and the gap is already there: around 185 km/h at 30 FPS against around 227 km/h at ~350 FPS. The only thing different between those two runs is the frame rate (video).

To measure it without fooling myself I wrote a small repeatable harness: spawn the same car (an Adder) at the same spot, hold full throttle, log top speed and distance over a fixed run, five runs per config with the frame rate locked. Every number in this post is from that one car, so the exact figures move around if you pick something faster or slower. The frame-rate effect underneath is the same either way.

Stock game065130195260185.2~30 FPS227.3~350 FPSkm/h
Average top speed over five runs on a flat straight road, stock game. Higher frame rate gives higher top speed, about 23% more at ~350 FPS.

How a wheel works, once per frame

Think of each wheel as a spring with a shock absorber, like on a motorbike. Every rendered frame, the engine does this once:

  1. It fires a ray straight down to find the ground and measure how far the suspension is compressed.
  2. From that it works out the spring force, the damper force, and the grip between tyre and road.
  3. It integrates those forces to update the car's velocity.

The important bit is once per frame. The whole wheel update runs off your rendering loop. Nothing in it runs on its own clock, and that's where the bug comes from.

Why it happens: the tyre model runs at your frame rate

The integrator does try to be frame-rate independent. ProcessIntegrationTask splits a long frame into a handful of smaller RK4 integration passes, and the number of passes falls out of the frame time roughly like this:

steps = max(1, floor(frameTime * 60 + 1));   // 60 = the target rate
dt    = frameTime / steps;

You can't really get the real count from that alone. The number of timeslices per frame is decided elsewhere in the engine, so the only honest way to know is to measure it. I hooked the integrator and counted how many times it actually runs per real second, at locked frame rates.

  • 30 FPS: about 120 integrations a second
  • 58 FPS: about 110
  • 64 FPS: about 65
  • 240 FPS: about 240

Below 60 FPS the engine already runs the physics in two timeslices per frame (that call happens up in the physics step, not in the wheel code), so 30 FPS isn't starved at all. It sits at about ~120/s. Then the moment you cross 60 FPS the engine drops to one timeslice, so the integration rate gets cut in half. A car at 61 FPS integrates fewer times a second than one at 30.

stock (measured)with fix0601201802403060120180240rendered FPSintegrations / s2 slices | 1 slice~120/s up to 60 FPS61/s at 61 FPS, below 30 FPS240/s at 240
Integrations per real second against frame rate, measured in-game. Below 60 FPS the engine runs 2 timeslices (about 120/s). Above it, 1 timeslice (equal to your FPS), so there's a cliff at 61 and then a straight climb. The fix, in green, pins it to a flat 120/s.

You'd expect more frames to just make the game look smoother, but here it actually makes the car faster. The reason is that the tyre grip model isn't linear. Grip against wheel slip is a curve with a peak, and every integration step re-evaluates it from the updated state. Step coarsely and the state drifts past the peak between steps, so the forces you apply end up a bit below max grip. Step finely and you sit right on the peak. More grip, higher top speed. Per step it's tiny, but add it up over every second of full throttle and you get the gap you measured.

The 55 vs 70 FPS test

If top speed really tracks integrations per second and not frames per second, then the cliff predicts a car at 55 FPS (two timeslices, ~110/s) beating a car at 70 FPS (one timeslice, ~70/s). Higher frame rate, lower speed. So I locked both and ran each one six times:

Locked FPStimeslicesintegrations/stop speed (6-run avg)
552~110186.3 km/h
701~70182.6 km/h

The two sets of runs don't even overlap. 55 FPS never dropped below 185.4, and 70 FPS never got above 183.0. The car that renders faster is reliably the slower one, because it integrates half as often. Smoothness has nothing to do with the top speed here. What sets it is how often the physics integrates per second.

Here's the same idea running live. Two panels running identical code on an identical flat road, and the only thing that differs is the frame rate, which sets the integration rate. Keep an eye on the little grip vs slip gauge: the 240 FPS car integrates finely and its dot sits right on the peak, the 30 FPS car integrates coarsely and sits off to the side with less grip, so it tops out slower.

Now flip the ConVar. Both sims pin to a fixed ~120 integrations/s (the readout says locked) and the speeds meet in the middle. Notice the 30 FPS car barely moves, because below 60 FPS it was already running at ~120/s, basically already correct. It's the 240 FPS car that drops back down. And 240 FPS is actually integrating more accurately than the game was ever balanced for, so the fix throws that accuracy away on purpose, just to match what everyone at 60 and under already gets.

It's a simplified longitudinal model, with the terminal speeds tuned to the real measurements (~185 km/h at 120/s, ~203 at 240/s). The real numbers live in the tables. The toy is just there so you can see why finer integration means more speed.

The fix: give the physics its own clock

If the problem is that the wheel physics has no clock of its own, the fix is to hand it one:

Run the wheel physics at a fixed 60 Hz. One step per 1/60 s of real time, whatever the frame rate.

Then everyone integrates the same number of times per real second, so everyone tops out at the same speed. High-FPS players still see a smoother game. The physics just runs on its own steady clock underneath.

I did it as a trampoline hook on ProcessIntegrationTask with a per-vehicle time accumulator. Picture a bucket that fills up with real time. Whenever it holds a full 1/60 s, we run one physics step and drain 1/60 back out.

state = &g_state[collider];               // each vehicle has its own bucket
state->lastFrame = frameCount;
 
// Cap at two steps so a hitch can't queue a burst against one stale contact sample
state->accum = std::min(state->accum + timeStep, 2.0f * kPhysicsStep);
state->sinceLastRun += timeStep;
 
stepsToRun = static_cast<int>(state->accum / kPhysicsStep);
state->accum -= static_cast<float>(stepsToRun) * kPhysicsStep;

At 30 to 60 FPS the bucket fills at least once a frame, so it behaves like vanilla's ~120/s and barely changes anything. At 240 FPS each frame only pours in 1/240 s, so it takes about 4 frames to fill the bucket. We run one step and skip the other three, which drops it back to ~120/s.

Each fixed step still splits internally into two loops, so the tyre model lands on a constant 120 integrations/s at any frame rate, the flat green line in the chart above. And it does exactly what the mechanism says it should:

Locked FPSvanillawith fix
70 FPS182.6185.0
218 FPS202.7184.0

Turn the fix on and a 20 km/h spread collapses to about 1. The direction matters too: 70 FPS got faster, because the fix hands back the integrations vanilla was skipping, while 218 FPS got slower. Both land on the ~120/s baseline. A fix that speeds a 70 FPS car up only makes sense if the integration count was what set the speed in the first place.

That's the idea. What took three rounds of testing in-game was everything that broke because we now skip frames.

Three things that broke (and why)

1. Skipped frames still have to answer

The caller reads a result straight back out of a local struct the moment the call returns. On a skipped frame we compute nothing, but we still have to hand back a sane value or the car jerks. So we cache the last real output and repeat it:

if (stepsToRun == 0)
{
    // Skipped frame: hold the last step's output (the only thing read back)
    locals->outputVel[0] = state->lastOutput[0];
    locals->outputVel[1] = state->lastOutput[1];
    locals->outputVel[2] = state->lastOutput[2];
    locals->outputVel[3] = state->lastOutput[3];
    return;
}

2. The car slid for two seconds at high FPS

At 240 FPS the car would drift for a second or two after every landing before it gripped. The damper needs the compression speed, which is how much the compression changed divided by how long that took. The ground reading still updates every frame, but we only run the physics every few frames now, so way more real time has passed than the 1/60 we hand in. The measured compression speed came out several times too small, the damper went weak, the suspension under-damped, and the car bounced around until it settled.

The fix rebuilds the "old compression" value so the delta covers the real window since our last run:

if (state->hasRunOnce && state->sinceLastRun > 0.0f && state->sinceLastRun <= 0.1f)
{
    for (int i = 0; i < trackedWheels; i++)
    {
        auto compression = reinterpret_cast<float*>(wheels[i] + g_wheelCompressionOffset);
        float trueDelta  = compression[0] - state->lastCompression[i];
        compression[1]   = compression[0] - trueDelta * (kPhysicsStep / state->sinceLastRun);
    }
}

Read it as: take the compression change that really happened over these ~13 ms, and rescale it as if it happened over 1/60 s, so that when the formula divides by 1/60 it gets the true speed back. At 60 FPS sinceLastRun is already 1/60, so it does nothing. The <= 0.1 guard skips it after teleports, where stale history would inject a fake spike.

3. Landings fell in slow motion

After a jump the car would float down in slow motion, but only at high FPS. This one was a units bug. For ordinary cars (rigid colliders) the wheel code doesn't apply its force right away. It piles it into a force accumulator on the collider, and the simulator integrates that later using the real frame time, not our 1/60. So if we compute a force meant for 1/60 s but the simulator spreads it over 1/240 s, only a quarter of it lands, gravity included, which is why the car drifts down in slow motion.

So we snapshot the accumulator before the run and rescale only what our run added, by 1/60 over the real frame time:

int colliderType = *reinterpret_cast<const int*>(static_cast<const char*>(collider) + kColliderTypeOffset);
auto forceAccum  = reinterpret_cast<float*>(static_cast<char*>(collider) + kColliderForceOffset);
 
float forceBefore[8];
if (colliderType == 0)
    memcpy(forceBefore, forceAccum, sizeof(forceBefore));   // snapshot before
 
/* ... run the steps ... */
 
if (colliderType == 0)
{
    float scale = kPhysicsStep / timeStep;
    for (int lane = 0; lane < 8; lane++)               // xyz of force + torque; leave the W lanes
        if (lane != 3 && lane != 7)
            forceAccum[lane] = forceBefore[lane] + (forceAccum[lane] - forceBefore[lane]) * scale;
}

At 60 FPS or below scale is 1 and it does nothing. At high FPS it cancels the lost fraction exactly. It's guarded on colliderType == 0 because motorbikes and other articulated colliders turn force into impulse right away with our own step, so they're already consistent and must not get rescaled.

Results

Same harness, five runs per config, flat straight road, before and after the patch:

Before fixAfter fix065130195260185.2181.7~30 FPS227.3185.4~350 FPSkm/h
Average top speed before and after. The high-FPS bar drops onto the low-FPS baseline, so the frame-rate advantage is gone.
Five-run average~30 FPS~350 FPS
Top speed, before185.2 km/h227.3 km/h
Top speed, after181.7 km/h185.4 km/h
Distance in run, before757.5 m932.2 m
Distance in run, after744.1 m756.9 m

The spread across frame rates went from about 23% down to about 2%, which is inside the run-to-run noise. It also lands in the right place: a 350 FPS client with the fix (185.4 km/h) sits right on the 30 FPS stock baseline (185.2). The fix pulls out the high-FPS boost without touching how the car handles at the frame rate the game was actually tuned for. Full per-run tables are in the PR.

The patch is up as FiveM PR #4059, behind a replicated ConVar, game_enableFrameRateIndependentWheelPhysics, off by default. A server opts in, and every client on it gets the same handling no matter their frame rate. Since at 60 FPS every correction is a mathematical no-op, the reference feel stays exactly as it was.