How can I make Halo-like vehicle physics?

Godot Version

3.6

Question

I’m trying to replicate Halo’s vehicle physics, specifically the warthog (as it’s the most complex), but I keep running into issues with the steering mechanics and suspension. I’m using a custom RigidBody solution because the class needs to share features with other vehicle classes and the documentation says that VehicleBody has unspecified “issues”.

So far I have tried two approaches to steering: a traditional, wheel-driven approach that uses deceleration forces and wheel orientation to move and turn the vehicle; and an “arcade-y” solution that uses central forces to drive the vehicle and torque to rotate it (which I suspect is what Halo actually uses). Each approach has its own problems.

The traditional approach pushes on the vehicle in ways that make it unstable and interfere with its turning. The exact nature of the problem changes depending on where the forces are applied. It’s mostly stable when applied to the wheel’s origin, but the body still rotates (because it’s being “pushed” from its corners) and it stops when I try to turn to far.

The “arcade-y” approach works exactly as intended, but it has two main issues: 1, the only way I can think of to prevent extreme overshoot without overriding the angular velocity (which is off the table) is to use a spring force, which makes tuning difficult because it doesn’t have a turning speed; and 2, it’s hard to scale with speed and there’s no deceleration acting on angular velocity because turning is completely separate from the driving code.

Finally, suspension doesn’t seem to act how I would expect it to. I’m using RayCasts to apply a spring force to the body. It always depenetrates fully from the ground, even when I would expect it to merely lift the wheel. This leads to a fairly bumpy ride even with a low stiffness setting on the spring force, and makes it feel really bad when hitting a steep ramp because of the way it abruptly snaps the vehicle’s front end up.

Also, now that I think about it, the wheels also continue to function on their sides, which tends to flip the vehicle back over and makes it feel more like a hovercraft than a car. This is obviously because RayCasts don’t have “sides”, but I don’t know how to fix it.

Are there any other approaches I can take for these things? Or solutions?

You’re posing a lot of questions here. I’ll try to answer them as best as I can. However, there are also things that need further clarification.

In what way does this observed instability manifest itself? Does the body start to roll over making it lose contact with the ground? Please clarify this point – “the body still rotates is a rather vague description since rotation is expected for a vehicle that is turning.

What are you referring to here when you say “extreme overshoot” and “turning is completely separate from the driving code”? This whole paragraph about the arcade approach leans heavily on knowledge only you possess. As a first-time reader, this paragraph is hard to decipher.

In order to determine what is causing the undesired spring behaviour, I would have to look at the code you’ve written for it. Please share that so I can provide feedback.

I believe I recognize this issue and can share a solution. However, it would (once again) still be nice to see your current implementation so I can provide concrete feedback.


I believe I can help you solve these initial hurdles but you have to give me more information to work with. For example:

  • What does your spring implementation look like?
  • What does your tire implementation look like?
  • How is acceleration and braking handled in your system?
  • How does the arcade approach differ from the traditional approach?
    • Is only the steering method different, or are there more things that separate the two?
  • What are the primary issues you’re experiencing?
    • Make a brief list of the problems you are experiencing.

Code and video are especially important to provide so I can get an insight into how your current system behaves.

The actual code is spread awkwardly across multiple nodes and functions because I haven’t settled on the final system yet, so I tried to clean it up to look uniform here. Just imagine all of the code running in a “for wheel in wheels” loop and you’ll be able to make sense of this version.

It’s important to note that, in both the “realistic” version and the “arcade” version of the code, the vehicle steers itself to face the direction the camera is facing (using wrapf(camera_rotation.y - global_rotation.y, -PI, PI) as the target angle). You’ll see this in more detail in Acceleration and Braking.

Suspension Implementation

var current_length := wheel.global_position.distance_to(wheel.get_collision_point())
var rest_length := wheel.suspension_length - wheel.radius
var compression := rest_length - current_length
var velocity := wheel.last_length - current_length
var spring := stiffness * compression
var damper := wheel.damping * velocity
var force := get_collision_normal() * max((spring + damper) / state.step, 0.0)
state.add_force(force, get_collision_point() - state.transform.origin)
wheel.last_length = current_length

I have the vehicle rest midway through the raycast because I want the wheels to hang down when the car is airborne and will use the ray length to drive that animation.

Problem 1: The wheel doesn’t feel like a wheel. I tried to multiplying the spring force with the result of wheel.get_collision_normal().dot(wheel.global_transform.basis.y) to fix it. It didn’t do much. I’m not sure what to make of this one.

Problem 2: Instead of rolling over obstacles that I would expect it to roll over smoothly (like a small rock), the wheels instead push the entire vehicle up every time, leading to a very bumpy ride.
It’s especially apparent when the vehicle hits a very steep ramp of any kind. Instead of crashing, the vehicle very quickly snaps up because it’s front end is getting rapidly and completely lifted to depenetrate the ray, and if the wall is steep enough, it kinda springs off of it.

Problem 3: I forgot to mention in my original post that the rest length doesn’t work correctly. The vehicle will float higher or lower depending on the stiffness and damping values.

Tire Implementation

I don’t know what this means, honestly. I’m not doing any sort of simulation, if that’s what you mean. My end goal is to replicate Halo’s style of car physics, which don’t appear to be all that deep.

Acceleration and Braking

This is the current “realistic” setup. It’s just acceleration and deceleration, but it actually uses the wheels to turn.

var steer_target := wrapf(camera_rotation.y - global_rotation.y, -PI, PI)
	
if wheel.steering == true:
	wheel.rotation.y = move_toward(wheel.rotation.y, steer_target, max_turn_rate)
	wheel.rotation.y = clamp(wheel.rotation.y, -max_turn, max_turn)

var drive_dir := Vector3.FORWARD.rotated(Vector3.UP, wheel.global_rotation.y)
var slide_dir := Vector3.RIGHT.rotated(Vector3.UP, wheel.global_rotation.y)

var wheel_vel := state.get_velocity_at_local_position(global_transform.basis * wheel.position)
var drive_vel := wheel_vel.normalized().dot(drive_dir)
var slide_vel := wheel_vel.normalized().dot(slide_dir)
var current_speed := abs(drive_vel * wheel_vel.length())

var acceleration := Vector3.ZERO
if is_zero_approx(input_direction.y) == true:
	acceleration = -drive_dir * (deceleration_rate * drive_vel) * 2
elif current_speed < get_speed():
	acceleration = drive_dir * (-input_direction.y * acceleration_rate) * 2
acceleration -= slide_dir * (friction_deceleration_rate * slide_vel)

state.add_force(mass * (acceleration/4), wheel.global_position - global_position)

As you can see, it’s basically a cross between character-style acceleration/deceleration and actual realistic vehicle physics. There’s some weirdness in there where I multiply by 2 and divide by 4, that’s compensating for the fact that this code is run once per wheel and the rates and max speed values are all for the vehicle as a whole.

Problem: The “instability” I mentioned has to do with add_force(). If I add the force to wheel.get_collision_point() - global_position, it pushes the vehicle from the wheels, thereby causing it to tilt back and making it very unstable as shown in basically every tutorial on raycast vehicles out there; applying it to wheel.global_position - global_position is much more stable but it still tilts the vehicle (only forward this time).

It also has the problem of skidding to a stop on tight turns sometimes, which is probably a realistic behavior, but isn’t desirable. This might be related to the above problem, so I didn’t give it its own label.

The “Arcade” Approach

This approach solves most of the issues introduced by the “realistic” solution in the above section and appears to be the same method used in Halo (or similar at least), but it comes with its own set of problems.

It works by driving the vehicle using add_central_force() and simple acceleration/deceleration forces. The vehicle steers by using add_torque() to rotate the vehicle around Vector3.UP toward steer_target. This means that steering and driving are completely separate things that cannot influence each other without some form of mathematical conversion between the two.

This would look something like this, but I deleted the original code so this is reconstructed from memory and untested:

var drive_dir := Vector3.FORWARD
var slide_dir := Vector3.RIGHT

var drive_vel := linear_velocity.normalized().dot(drive_dir)
var slide_vel := linear_velocity.normalized().dot(slide_dir)
var current_speed := abs(drive_vel * wheel_vel.length())

var acceleration := Vector3.ZERO
if is_zero_approx(input_direction.y) == true:
	acceleration = -drive_dir * (deceleration_rate * drive_vel) * 2
elif current_speed < max_speed:
	acceleration = drive_dir * (-input_direction.y * acceleration_rate) * 2
acceleration -= slide_dir * (friction_deceleration_rate * slide_vel)

state.add_central_force(mass * (acceleration/4))

var steering_diff := wrapf(camera_rotation.y - global_rotation.y, -PI, PI)
state.add_torque((steering_diff * turn_stiffness) - (turn_damping * angular_velocity.y), Vector3.UP)

Problem 1: is the “extreme overshoot” I mentioned before. Both versions overshoot the target direction (which is actually intentional), but arcade approach doesn’t compensate for it at all and will start zigzagging forever as it overshoots the target more and more. The only solution I came up with is to use an angular spring+damper force to rotate the vehicle, but in doing so eliminates the ability to control the turning speed or limit the angle of the turn.

Problem 2: Deceleration forces only interact with the linear velocity, so the vehicle spins indefinitely when not driving. I could apply the linear deceleration force as-is to the steering force, but if angular deceleration and linear deceleration aren’t perfectly in sync, the illusion will break as the vehicle stops moving and rotating at different times.

Problem 3: Because steering and driving are separate, scaling the steering speed is a lot harder. I suspect it wouldn’t be much of a problem if I can solve problem 1 without using a spring+damper system for steering, though.

I’m sure these problems could be solved by someone with better math/physics knowledge than me. If it is possible, I would prefer to use this approach over the “realistic” one because it’s much closer to how I want vehicles to feel.

A Few Notes

At some point during my last revision, I changed something that made reversing behave incorrectly (if you’re familiar with Halo’s vehicle controls, you might have noticed it). The only reason I haven’t fixed it yet is because I’m too lazy to do so in the face of the more difficult issues. It’ll probably be an easy fix once I get the time, so you can ignore it.

It’s been a while since I tried, but I don’t think the forum will let me include an attachment yet. This is my first post. Hopefully I was able to describe everything well enough.

Suspension notes

The majority of your suspension system makes sense. You’re using a traditional spring equation with damping to produce a suspension that should stabilize the car over time. However, I would recommend computing a normalized compression value that sits in the [0, 1] range instead of the [0, suspension_length] range you’re currently using.

float compression = (rest_length - current_length) / rest_length

This allows you to freely adjust the spring length without worrying about the stiffness parameter.
NOTE: In the case of wanting to use a rest_length, ignore this (see section Re: Problem 3).

What is the reason behind using the get_collision_normal() (i.e. the ground normal) as the direction for the spring force; is this a creative decision? Normally the force of a spring only acts on its own axis.

Re: Problem 1

I have to admit, I’m at a loss as to what the problem of “The wheel doesn’t feel like a wheel” even is. It’s a very subjective statement that doesn’t really explain why you would attempt to scale the spring force with it’s alignment to the ground.

Re: Problem 2

This is a normal outcome of using a ray-based wheel/suspension system. Since the ray only accounts for information at a single point, the system does not react to height changes in the same way a physical cylinder does.

To produce a wheel/suspension system with more realistic looking behaviour, you could either use an array of raycasts or use a shapecast to approximate the shape of the wheel. Both approaches allow the system to naturally, and gradually, react to the incoming geometry change which will produce a smoother response.

Re: Problem 3

This occurs because your system is currently applying 0 force when the suspension is at its rest_length. At rest, all springs should, in unison, apply the exact amount of force needed to counteract the gravitational force acting on the body. This makes the system slightly more complicated as you now have to manage the spring parameters outside the class; you can’t determine how stiff the spring should be for its rest position if you don’t know how much weight it should counteract. A 1000kg vehicle with 4 wheels will see its weight distributed on those 4 wheels: 1000kg / 4 = 250kg (when all those wheels are grounded). If you want the spring for each wheel to rest halfway, the spring should produce this amount of force at that rest position: 250kg * g.

In consequence, the stiffness now depends on the rest_length – it is automatically computed.

# NOTE: This function should be invoked in _ready() on the parent.
func update_stiffness_from_vehicle_configuration(vehicle_body: RigidBody3D, total_spring_count: float):
	float g = ProjectSettings.get_setting("physics/3d/default_gravity")
	float rest_force = vehicle_body.mass * vehicle_body.gravity_scale * g / total_spring_count
	
	float stiffness = rest_force / (spring_length - rest_length)

Be careful not to set the rest_length too close to the max length of the spring. This will produce a very high stiffness value. You should probably perform a check to ensure that rest_length is lower than the spring_length.

The Arcade Approach

Re: Problem 1 & 2 & 3

I see what you mean. What is the reasoning behind you not wanting to directly override the angular_velocity of the vehicle? Why does it have to be a force? It would be really easy to just do:

Vector3 angVel = state.angular_velocity
angVel.y = clamp(drive_vel, 0, 1) * steering_diff
state.angular_velocity = angVel

Let me know what you think and how it’s going.

I just realized… “the wheel doesn’t feel like a wheel” – does that refer to the fact that you thought the car felt more like a hovercraft?

What do you mean by this?

I don’t remember… it might have been to solve a bug (I do remember choosing it, specifically), but when testing with global_transform.basis.y it doesn’t appear to have any problems.

Ah sorry, I should have been more clear: Suspension P1 is the issue that you thought you recognized, where the wheels continue to interact with the ground when the vehicle is on its side. I know why it happens (because it’s a cast), but not how to “fix” it.

I am planning to switch to a shape cast later, but I think you misunderstood my meaning. It’s a bit hard to explain… Imagine a car with one wheel on an obstacle: that wheel wouldn’t be fully extended, would it? Even though the car is being raised by the suspension, the wheel doesn’t push the body up enough for the spring to fully relax. Mine always pushes the body up the whole distance, for every wheel, no matter what.

I tried to add this and it made the suspension rock hard and it jumps really a lot when it first lands on the ground. The code is the same as my original except for these lines:

var rest_length := suspension_length * 0.5 # I changed this because I realized the original doesn't make much sense.
var spring := ( 24.525 / (suspension_length - rest_length) ) * compression # Temporary constant implementation.

Because doing so would override external forces, meaning it couldn’t react to crashes, explosions, or anything else. I’m trying to replicate the physics of Halo’s vehicles, so that’s pretty important.

Okay, okay, okay. I figured out the problem with your suspension code – it’s been a while since I’ve looked at this kind of stuff so it’s sometimes hard to understand just by looking at code.

Mistake #1

var force := get_collision_normal() * max((spring + damper) / state.step, 0.0)

The force is being incorrectly calculated in terms of how it is subsequently used. You should not scale the force by the delta (state.step) because apply_force() expects the force to be expressed in terms of N/s. The force is automatically multiplied by delta when applied via apply_force(). Note that in your case you’re, for some reason, dividing with the delta which scales the force to an absurd amount. If we assume default physics settings, It’s the same as multiplying the force by 60.

For these reasons, change the force computation to the following:

var force = global_basis.y * max(spring + damper, 0.0)

Have a look at both apply_force() and apply_impulse() in the documentation.

Mistake #2

state.add_force(force, get_collision_point() - state.transform.origin)

First off, I would recommend that you use _physics_process() over _integrate_forces(). There is nothing wrong with using the state to apply forces but remember that _integrate_forces() does not run when the body is sleeping. I have had problems in the past where this has caused bugs. Bear this is mind.

The actual problem here is that you’re applying the spring force at the contact point where the wheel sits. This is incorrect. A spring will always apply its force to the point where it is attached. In this case, it will be the wheel well (or wherever the raycast starts).

Change it to the following:

# Inside a separate wheel class (_physics_process())
vehicle_body.apply_force(force, vehicle_body.global_basis * position)

or use this if you are keen on maintaining your current setup

# Inside the vehicle class (_physics_process())
apply_force(force, global_basis * wheel.position)

Mistake #3

var compression := rest_length - current_length

This is the aforementioned incorrect way of implementing a rest-height feature. As previously mentioned, the rest_length influences the stiffness – it should not directly affect the compression.

Read the “rest height” part of the code shown below.


I’ve made a HaloWheel script for you that fixes nearly every issue. Have a look at it below and make any corrections if you feel the need to do so. It still doesn’t fix the issue of “not feeling like a wheel”. I will get around to posting a solution for that later. In the meantime, see if this doesn’t work for you.

I will have a look at your other questions later.

extends RayCast3D

@onready var wheel_mesh : Node3D = $Mesh
var vehicle_body : RigidBody3D

@export var suspension_length = 1.0
@export var stiffness = 1000 # Due to the rest-height feature, this value is overridden
@export var damping = 1000
@export var radius = 0.5

var last_length: float

func _ready():
	# Retrieve reference to the parent
	vehicle_body = get_parent() as RigidBody3D
	
	# Set end-point for raycast
	target_position = Vector3.DOWN * suspension_length

func _physics_process(delta: float) -> void:
	var current_length := global_position.distance_to(get_collision_point())
	
	# Rest height system
	var rest_length = suspension_length - radius
	var g = ProjectSettings.get_setting("physics/3d/default_gravity")
	var rest_force = vehicle_body.mass * vehicle_body.gravity_scale * g / 4
	stiffness = rest_force / (suspension_length - rest_length)
	
	# Compute and apply spring forces
	var compression = suspension_length - current_length
	var velocity = last_length - current_length
	var spring = stiffness * compression
	var damper = damping * velocity
	var force = global_basis.y * max(spring + damper, 0.0)
	vehicle_body.apply_force(force, vehicle_body.global_basis * position)
	last_length = current_length

	# Position wheel at the collision_point
	wheel_mesh.position = get_collision_point() + global_basis.y * radius

Oops… why did I do that?

I know. can_sleep is unchecked while the vehicle is being piloted so it should be fine.

Before I did this, it felt more like it was being rotated by thrusters than pushed off of the ground by wheels. I found this solution when I researched the problem and it seemed to work, but maybe it won’t be necessary if I can fix the rest of the code.

I adapted your changes and now the vehicle acts like it’s floating on water and keeps losing traction (traction is a simple current_length <= rest_length check to prevent weirdness). I turned up the damping and it still has the same problem, just with smaller “waves”.

It’s fairly obvious why traction is being lost when current_length <= rest_length is your condition. Since this spring will naturally sit at the rest height, it will often go over or under your threshold when driving around. I don’t see how your traction condition makes sense to implement. To me, you lose traction when the wheel is not touching the ground i.e. when the raycast is no longer hitting anything. Why are you interpreting the rest_length as the max length?

With regards to the vehicle feeling like it’s floating on water, I guess making a realistic spring doesn’t make sense since you don’t have any tire simulation going on. Normally, the swaying motion you’re seeing is counteracted by the traction forces exhibited by the wheel’s tire. The spring code I’ve provided doesn’t produce realistic looking results for non-realistic systems. Apologies. I didn’t think about that.

Perhaps reverting to using the get_collision_point() for the force position, or the get_collision_normal() for the force direction will yield better results in your system.

It still loses traction when sitting still, too.

The vehicle will be able to steer when airborne if I use the whole length that way. Remember, the only reason I’m using rest length it to drive the suspension animation that makes wheels hang down when airborne (0.0: raised, 0.5: normal, 1.0: lowered), meaning that the vehicle’s weight isn’t on those wheels. It doesn’t make sense to me that it would be possible to drive and steer with wheels that are barely grounded at all. The main purpose of that extra length is to prevent the wheel from clipping through the ground.

I should probably add a bit of leeway to account for floating point errors, though.

The stiffness seems way too low for suspension. It’s more of a lazy bob than a spring. I can stop the bobbing by setting damping to 50,000+, but the suspension itself 's still so squishy that the vehicle crashes into the smallest hill before it even begins to lift the vehicle.

I’m going to try to be really concrete now because it feels like we are not on the same page.


As far as I can tell, your aim is to produce a traditional spring where the wheel (which is purely visual) hangs below the effective length of the spring when in the air. Implementing a rest length will, on its own, not produce the desired visual aspect – it only produces a spring that rests at a certain length by calibrating it in relation to the overall system (the vehicle). Because a spring responds linearly to displacement, a rest length of 50% results in a quite weak spring because the maximum potential output of the spring is only 1 / (1.0 - 0.5) = 2 times the weight it supports. Conversely, a rest length of 90% would produce a rather strong spring capable of supporting 1 / (1.0 - 0.9) = 10 times the weight at maximum compression.

Sidenote: spring falloff

You could try to experiment with different falloffs for the spring; non-linear springs that may produce a preferred physics response. I have never tried this myself though.

If you wish to also support the “hanging wheel”, you will have to divide the spring (i.e. the raycast) up into two separate parts: the spring’s effective length, and a buffer enabling terrain-adaptive positioning of an object (i.e. your wheel mesh).

extends RayCast3D

@onready var wheel_mesh : Node3D = $Mesh
var vehicle_body : RigidBody3D

@export var suspension_length = 1.0
@export var mesh_buffer_length = 0.5
var full_length:
	get: return suspension_length + mesh_buffer_length
@export_range(0.0, 1.0) var rest_length = 0.5
@export var damping = 5000
var stiffness = 0 # Computed through the "rest height" system

@export var wheel_radius = 0.5

var last_length: float

func _ready():
	# Retrieve reference to the parent
	vehicle_body = get_parent() as RigidBody3D
	
	# Set end-point for raycast
	target_position = Vector3.DOWN * full_length
	
	# Initialize last_length to avoid initial large velocity values
	last_length = full_length

func _physics_process(delta: float) -> void:
	var current_length: float
	if is_colliding():
		current_length = global_position.distance_to(get_collision_point())
	else:
		current_length = full_length
	
	# Rest height system
	var g = ProjectSettings.get_setting("physics/3d/default_gravity")
	var rest_force = vehicle_body.mass * vehicle_body.gravity_scale * g / 4
	stiffness = rest_force / (suspension_length - suspension_length * rest_length)
	
	# Compute and apply spring forces
	var compression = max(0, suspension_length - current_length)
	var velocity = (last_length - current_length) / delta
	var spring = stiffness * compression
	var damper = damping * velocity
	var force = global_basis.y * max(spring + damper, 0.0)
	vehicle_body.apply_force(force, vehicle_body.global_basis * position)
	
	last_length = current_length

	# Position wheel at the collision_point
	wheel_mesh.global_position = global_position - global_basis.y * (current_length - wheel_radius)

Summary of changes:

  • Changed the rest_length implementation to represent a percentage of the suspension_length. This should prevent the need to adjust the rest_length when the suspension_length is changed.
  • Initialized last_length in _ready() to avoid massive velocity values on first contact
  • Added a max() expression to the compression calculation now that the current_length can represent values beyond the suspension_length.
  • Fixed the velocity calculation to represent the correct SI unit of metres per second (m/s). This has the effect of needing lower damping values.

I hope this is more satisfactory to the suspension system you’ve been envisioning. You should probably change your traction condition to current_length <= suspension_length or add a separate threshold for traction.

Let me know what you think.

EDIT: I just realized the wheel position is off in the gif. I forgot to correct that when testing. It is fixed in the code I’ve posted.

I think you may have misunderstood what I meant by “rest length” and “suspension length”. In my code, rest_length is supposed to be the length of the spring (this appears to be what you’re calling suspension_length), whereas suspension_length represents the wheel’s full range of motion (equivalent to your full_length). Probably a terminology error on my part, but I’m not sure what would be better.

The problem I had was that the vehicle would sit at more or less of the ray’s length than intended if the stiffness was too high (say, 75% instead of 50%). Given that current_length / suspension_length is will drive the suspension animations, it was very bad.

…how did I miss that? I bet it made suspension frame-rate dependent, too. Thanks for catching it.

Now that I think about it, I should change var force = global_basis.y * max(spring + damper, 0.0) to var force = global_basis.y * (spring + damper). I probably do want the damper to apply a downward force sometimes.

Don’t worry about it. I’m not going to position the wheel through code, anyway; I’m going to use current_length / suspension_length to drive a hand-made animation on the vehicle, that way the wheel can be attached to the suspension mesh.