In Depth Scripted Car Physics

SpringConstraints can be a pain to work with and don’t offer much customization. Well, in this tutorial I will be explaining everything I know about car physics and how to make it. From the very basics of suspension all the way to full control over how a car’s traction is handled.

This tutorial will be split into parts:

  1. Prerequisites: These are topics you should know before getting started, I will explain a little bit about them in this section, but you should do research on your own
  2. Explanation: These appear before the programming begins for that section. It will explain the math behind what is about to be done and how we get there
  3. Basics: These are the tutorials I would consider the bare minimum required to make a car system. This would be things like suspension, movement, steering, etc
  4. Advanced: These are the tutorials that are a bit more advanced and allow for your car system to feel more life-like. This would be things like, advanced wheel casting and throttle curves

Disclaimer: This is a tutorial aimed to teach you how to create car physics. I’ve skipped over a lot of optimization methods in favor for cleaner and easier to understand code.


I. SUSPENSION

Prerequisites
  • Raycasting: Of course, you will need to know how this works because the whole system is built on the raycast. Raycasting is when you shoot out an invisible line in a certain direction and it will give you the part that intersects that line first (closest to the start of the line). Raycasts can also be limited by how far they travel so they don’t always need to travel out to infinity, we will be using this to our advantage
  • Basic knowledge of CFrames: Simply need to know what CFrames are and what the difference between (CFrame * CFrame) and (CFrame + Vector3)
  • Local vs World: Know the difference between local space and world space
  • Algebra level math: I will do my best to explain the math used here, but it’s kinda hard to explain when you don’t know the terms (like coefficient)
  • Vectors: I will explain stuff like the Dot and Cross product, but have a basic understanding of vectors
  • Some Luau programming knowledge: I will explain the program, but basic stuff like syntax and programming terms I will not

Some helpful resources:
Understanding Raycasting - Resources / Community Tutorials - Developer Forum | Roblox

Comprehensive Beginner’s Guide to CFrames and How to Use Them [CFrame Guide] - Resources / Community Tutorials - Developer Forum | Roblox

Suspension: Explanation

Ok, time for a lot of reading, trust me it’s worth it. Car physics in games can be simplified down to “what is below this wheel and what should happen”. The very first thing you do is use a raycast to figure out how far away from the ground is the wheel

HOOKE’S LAW

Hooke’s Law is an equation that tells you how much force a spring is applying when it is being stretched or compressed passed its rest length; that equation is F = -kx. Now if you are confused right now, that makes sense don’t worry I’ll explain.

The rest length of a spring is the distance away from its connected part where it doesn’t apply any force. So, if you look at the picture the spring on the left is at rest, we’ll say it’s 5 inches away from the roof it’s hanging on. Therefore 5 inches is its rest length, it is neither falling or moving up (assume there is no gravity).

Now what Hooke’s Law is saying is, the force (F) is equal to the stiffness of the spring (k) multiplied by its distance away from its rest length (x). That “distance away” is very important, because you want force to equal 0 at rest length. Let’s say ‘k’ actually meant “distance away from connected roof” for example. Using the same picture as before, we would plug in 5 for ‘x’ (because it’s 5 inches away from the roof). And let’s say just 1 for ‘k’ (I’ll explain stiffness in a bit). That would give us a force of 5, which doesn’t make a lot of sense. The spring isn’t moving but our equation gave us F = 5? That’s why ‘x’ means “distance away from rest length”. We will call this “displacement”.

Now stiffness (k) is a constant. The stiffness of a spring will never change it is determined by the material the spring is made of. In games, stiffness can be whatever we want, it doesn’t matter.

Finally, you may be wondering, why is it negative kx? (F = -kx). This is saying that the force a spring is applying is always opposite of the the displacement. For example, if you took a spring and stretched it to the right, then let go the spring would move to the left. It is applying a force to opposite of the way you stretched it.

MASS DAMPED SPRING EQUATION

Now Hooke’s Law is great, but in a perfect world without any outside forces (such as air resistance), our spring will be moving back and forth and back and forth forever. This is where the mass damped spring equation helps us out. Yes, it’s a terrible name.

The equation goes like this: You start off with Hooke’s Law F = -kx then we need to know how much to subtract this by so the spring isn’t looping forever, damping is what does that. Damping is a force that opposes movement so, imagine you have a pool of water, and you punch move your hand through it underwater. That’s a bit harder to do that doing it outside the water. Now replace that water with honey, honey is thick and doesn’t want to move, it’s a lot harder now. If you try moving your hand faster the more honey or water will resist. This is how we figure out the damping equation. Now going back, the liquid made you move slower, faster you went; that is velocity. The spring force, F = -kx is a force depends on position, while the damping force depends on velocity. And just like the spring force, the damping force needs to act against velocity. This gives you the equation F = -cv where ‘v’ is the velocity and ‘c’ is the damping constant (which is the same as the stiffness constant, so we can set this to whatever we want).

Before we can combine these two functions, let’s talk a bit more about the ‘v’ in F = -cv. This is a velocity along the axis of the spring. That’s a lot of words so here’s the breakdown. A velocity along an axis is just a number, imagine a number line with 0 in the middle, negatives on the left, and positives on the right. Now we have a circle moving left and right on that number line, the velocity along the number line of that circle would be a positive number when moving right, and a negative number when moving left. If that doesn’t make a whole lot of sense, here’s a gif

vel-anim

As you can see, is it positive when going right and negative when going left

Now that we have all of this, we can combine the two equations. We get F = -kx - cv, this is our damped spring equation. But, look at the title of this section it says mass damped spring equation. For our case, we will be assuming that the mass of every wheel is 1. If you really want to add a mass to your wheels, do this: Use Newton’s second law, which is F = ma force = mass * acceleration. Your force would be from F = -kx - cv you are solving for acceleration, so
a = F/m = (-kx - cv)/m (can’t do fraction bars, sorry). This does come with some headaches; in this tutorial we will be applying forces not acceleration so you will have to figure out a way to apply this to the car yourself.

Now a quick review. F = -kx - cv is the damped spring equation, where ‘k’ is the stiffness constant, ‘x’ is the distance away from rest, ‘c’ is the damping constant, and ‘v’ is the velocity along the spring’s axis.

Why know this?

Ok, so I went on a long tangent of where the spring and damping forces come from. Now why do we even need to know this? The simple answer is it keeps the car from slamming on the ground. We are simulating suspension and suspension is all about the spring. After raycasting you plug in the displacement (remember not distance) into the equation and that is how much force you need to apply in order to keep the car from breaking.

Final thing we need to know before starting to program, forces have a direction. The force we calculate will just be a number but to apply that back to the wheel we need to convert it into a vector. The force must always be along the spring’s axis otherwise it will get all messed up and probably fling itself.

That is everything about springs for game design (in our case)

Suspension: Programming

Now the fun part begins, programming!

THE RIG

Rigging is the most annoying part of any programming project. A rig (in our case) is just the model you want to use to act as the car. There’s a few things you want to keep in mind:

  1. The front face of the base part of the rig must be facing the front of the car: Lot of words, this means that the front face of the part where your “springs” would be attached should be facing towards the front of the model
  2. Mass matters: The bigger your car is, the more force it needs to stay up. But at the same time, if you make everything massless, physics likes to bug out (especially Roblox physics). For this tutorial, it doesn’t matter but if you are combining this with Roblox’s constraints, then it does matter

Now let’s set up our amazing, perfect looking, no modeler can do better, car.

Isn’t she beautiful! All jokes aside, to keep things simple I won’t be using any model for this tutorial so you can easily see what is going on. The program we are about to write is modular, meaning you can put this on anything (as long as you set up the rig right)
If you want to follow me exactly, the dimensions are 7x1x13.5, with no rotation

Let’s add the wheels, we will be using 4 wheels here one in each corner.

You can make these wheels as big or as small (probably don’t go below half a stud) as you want. The position of the wheels also doesn’t really matter because later on we will be setting those positions through a script. You do not need to name the wheels, if you want that’s fine, but in the script it will be looking for parts not names.
For the exacts each wheel is 0.5x3x3 at a rotation of (90, 0, 0)

Now take everything you just done and put it in a model. This is just to make sure everything stays together, and you can easily move it around. Make sure to set up a folder for wheels and name your base part correctly.

Very simple. Finally, double check that everything is unanchored.

PROGRAMMING SETUP

Here we go. Create a new Script under the car’s model and name it CarController. At the very top we need to define some constants. From before, remember we need to know a stiffness constant and damping constant. These can be whatever and will have to be tweaked after we test, but for now let’s use 10 for stiffness and 1 for damping.
A good rule to go by is the rule of 10 (not what it’s called but I call it that) which means damping should be 10x less than stiffness

local STIFFNESS = 10
local DAMPING = 1

We also need to know the rest length of the springs. This is used later to figure out what the displacement is for F = -kx. I will use a rest length of 2 which means 2 studs from the center of the base part is the resting length of the spring.

local STIFFNESS = 10
local DAMPING = 1
local REST_LENGTH = 2

And finally, we need the radius of the wheel. If you don’t know what a radius is, it’s just the distance from the center of the wheel to the edge. To find this out, look back at your wheel part and look at the Size property. Whatever one is the largest divided by 2 is your radius, simple. In my case it’s ‘1.5’.

local STIFFNESS = 10
local DAMPING = 1
local REST_LENGTH = 2
local WHEEL_RADIUS = 1.5

Now with the basics out of the way, we need a function that is very useful. That is the “getVelocityAtPoint” function. For whatever reason, Roblox’s built in GetVelocityAtPosition function never works from my experience, so this function does the same but also let’s you know how the function is doing it. I will first show you it, then explain

local STIFFNESS = 10
local DAMPING = 1
local REST_LENGTH = 2
local WHEEL_RADIUS = 3

local function getVelocityAtPoint(part: BasePart, worldPoint: Vector3)
	return part.AssemblyLinearVelocity + part.AssemblyAngularVelocity:Cross(worldPoint - part.Position)
end

Yes, this looks confusing, especially if you have never taken physics. I’ll break it down, no matter what wherever you are on the part the velocity there has the current linear velocity of it. This is what part.AssemblyLinearVelocity is, it is the linear velocity of the part. Now what is this confusing mess that comes after part.AssemblyAngularVelocity:Cross(worldPoint - part.Position). part.AssemblyAngularVelocity is the current angular velocity of the part. Angular velocity is how fast the part is spinning basically. worldPoint - part.Position converts the world space point of “worldPoint” into a local space point relative to the center of part. the :Cross() is saying take the angular velocity and do the cross product on the relative point. Without taking a long time to explain what the cross product is, this gives us the additional linear velocity at that current point
Now why does that happen? Well, when something is spinning the parts of it which are further from the center have to move faster in order to keep up with the center. So, like a tire’s center moves slower (actually the center doesn’t move at all because it’s the center, but let’s say a point close to the center) than a point on the edge of the tire because of the extra distance it needs to make a full rotation.

The velocities have Assembly in front because that means it will add on the velocity of the entire assembly with it. An assembly is anything welded (either through a WeldConstraint or Weld) to this part. It doesn’t even need to be directly welded to it, this part could be welded to some part which is welded to another part

This function will be used when we are calculating the damping force. This is what is plugged into the ‘v’ in F = -cv. Well, after we of course make it along the spring’s axis.

SETTING UP THE WHEELS

Now you may have noticed when we were making our rig, it’s just parts. There’s nothing to move it, yeah we could manually move it by setting the position of every part, but that’s not a good idea. We will instead be using VectorForces these will keep our car in the air. To set this up, we will do this in code since it makes the rigging process suck less.

We will define a new function called initWheel this will be ran for every wheel in our car.

local model = script.Parent

local STIFFNESS = 10
local DAMPING = 1
local REST_LENGTH = 2
local WHEEL_RADIUS = 1.5

local function getVelocityAtPoint(part: BasePart, worldPoint: Vector3)
	return part.AssemblyLinearVelocity + part.AssemblyAngularVelocity:Cross(worldPoint - part.Position)
end

local function initWheel(wheel: BasePart)
	
end

for _, v in pairs(model.Wheels:GetChildren()) do
	if v:IsA("BasePart") then
		initWheel(v)
	end
end

I just typed a lot of stuff; I’ll go through it one by one.
First, we needed a reference to the car’s model, so I put that at the very top.
Next, I created our initWheel function and made a loop below it that will loop through every wheel, make sure they are BaseParts, then call the initWheel function for each.
You may have noticed when I put parameters into functions, they have like : BasePart and : Vector3 these are types, they are not required, but they give you intellisense (autocomplete)

For the suspension forces later on, we need a reference to the position of these wheels. Later on in the tutorial, these wheels will be moving so we can’t just use the position of the part. We are instead going to use Attachments to easily store the position

local model = script.Parent
local body = model.Body

local STIFFNESS = 10
local DAMPING = 1
local REST_LENGTH = 2
local WHEEL_RADIUS = 1.5

local wheels = {}

local function getVelocityAtPoint(part: BasePart, worldPoint: Vector3)
	return part.AssemblyLinearVelocity + part.AssemblyAngularVelocity:Cross(worldPoint - part.Position)
end

local function initWheel(wheel: BasePart)
	wheel.Anchored = false
	wheel.CanQuery = false
	wheel.CanTouch = false
	wheel.CanCollide = false
	
	local attachment = Instance.new("Attachment")
	attachment.Parent = body
	attachment.WorldPosition = wheel.Position
	
	table.insert(wheels, {
		attachment = attachment
	})
end

for _, v in pairs(model.Wheels:GetChildren()) do
	if v:IsA("BasePart") then
		initWheel(v)
	end
end

Quick note, it is good practice to always set the parent of a new instance you created through a script after you assigned all the properties. In our case we cannot do that since once you set the parent of an attachment, the WorldPosition gets changed, we need to make sure it’s in the right spot.

You also may have noticed I made the wheel unanchored, non collidable, non-quarriable, and not touchable. This is because the wheel part is all visual, it has nothing to do with the car. If the wheel was collidable, it would hit the floor and cause the car to bounce when it shouldn’t be bouncing.

SPRINGS!

We are finally done with setup and can move to applying all our new knowledge into code. Let’s make the function that gives us the damped spring force.

This is a two-step process:

  1. Calculate the velocity along the spring’s axis
  2. Plug into the damped spring equation

You should know how to get the velocity at the wheel’s position since was the first function we made

local function getSpringForce(worldWheelPos: Vector3, displacement: number): Vector3
	-- F = -kx - cv
	
	local velocity = getVelocityAtPoint(body, worldWheelPos)
end

As you can see, getSpringForce() takes in the world position of the wheel. When we are raycasting we will convert the local position of the wheel back to world.

Alright, now we need to get the velocity along the spring’s axis. This is done using the vector’s dot product. For this tutorial, all you need to know about the dot product is “it takes the parallel and discards the rest”. This means only the values that are parallel to the other vector in the dot product will be given.

local function getSpringForce(worldWheelPos: Vector3, displacement: number): Vector3
	-- F = -kx - cv
	
	local velocity = getVelocityAtPoint(body, worldWheelPos)
	local verticalSpeed = body.CFrame:VectorToWorldSpace(Vector3.yAxis):Dot(velocity)
end

Vector3.yAxis is the spring’s axis. The spring will only ever apply a force in the UP direction so that’s the speed we need to get. Now why body.CFrame:VectorToWorldSpace()? This rotates (0, 1, 0) to be the up vector of the body. This is to ensure that the speed we are getting is only along the spring’s axis, and since the spring rotates with the body this makes sense.
Sidenote: You can do “body.CFrame.UpVector:Dot(velocity)” which means the same thing in this tutorial, but if you read the advanced section, you will know why it is done this way (it allows you to have the direction of gravity to whatever you want)

Now that we have the vertical speed, it’s as simple as plugging into the formula. For easier reading, I have separated it.

local function getSpringForce(worldWheelPos: Vector3, displacement: number): Vector3
	-- F = -kx - cv
	
	local velocity = getVelocityAtPoint(body, worldWheelPos)
	local verticalSpeed = body.CFrame:VectorToWorldSpace(Vector3.yAxis):Dot(velocity)
	
	local springForce = -STIFFNESS * displacement
	local dampingForce = DAMPING * verticalSpeed
	
	local totalForce = springForce - dampingForce
	return totalForce * Vector3.yAxis
end

Simple, we multiply by Vector3.yAxis to get it back into vector form since the damped spring equation returns a number (again Vector3.yAxis is the spring’s axis).

APPLYING FORCES & RAYCASTING

It’s time to get these to actually do something.

Let’s go back to the initWheel function to add something new

local function initWheel(wheel: BasePart)
	wheel.Anchored = false
	wheel.CanQuery = false
	wheel.CanTouch = false
	wheel.CanCollide = false
	
	local attachment = Instance.new("Attachment")
	attachment.Parent = body
	attachment.WorldPosition = wheel.Position
	
	local springVectorForce = Instance.new("VectorForce")
	springVectorForce.Force = Vector3.zero
	springVectorForce.Attachment0 = attachment
	springVectorForce.Parent = attachment
	springVectorForce.Name = "Spring"
	springVectorForce.RelativeTo = Enum.ActuatorRelativeTo.World
	
	table.insert(wheels, {
		attachment = attachment,
		springForce = springVectorForce
	})
end

We need an actual vector force to apply all the forces. This is setup very easily, we make it relative to the world, so it doesn’t rotate with the body (always apply the force up). Then, we add it to the wheels table so we can reference it later.

Now it’s time to use all this new stuff we created! We will create a function called applyForces() this will figure out the displacement of the wheel that will then be plugged into the getSpringForce() function and then apply the force.

local function applyForces(wheel: { attachment: Attachment, springForce: VectorForce })
	
end

So, we need to figure out how far away is the wheel from the ground. That’s a tricky problem but that’s exactly what the raycast solves. We can use a raycast and it will give us a distance from the starting point.

To set this up we need to first know where the ray should start. That’s easy just the attachment’s WorldPosition. Now we need a direction, that’s a bit more confusing. Let’s first solve the end position, where should this ray end?

Your first thought may be infinity, but we don’t want that, why? Well, let’s think logically; we have a wheel that has a simulated spring which should do its best to keep the wheels on the ground. If we were to drive the car off a ramp, our raycast will still hit the ground and apply forces. It would say that we are so far away from the ground that a lot of force needs to be applied to fix this before it gets out of hand. Well, by applying that much force, everything just got out of hand and the car has been flung.

Ok so we don’t use infinity, then what? Well, we need the force to be applied when the car is touching the ground, but not when it’s not. This is where the REST_LENGTH comes in, we take the starting position of the ray and go down the rest length of the spring. But think longer once more; are we really done? Yes, we went the rest length of the spring, but wheels are flat, they have height! We need to keep going down to fit in the height of the wheel, which so happens to be the radius of the wheel since rest length moves the center of the wheel to the floor, then we need to move the radius of the wheel to get to the bottom of the wheel.

This ends up being start + REST_LENGTH + WHEEL_RADIUS for our ending position of the ray. This isn’t what workspace:Raycast() is asking for though, it’s asking for a direction. Well, a ray’s direction can be described as pointing somewhere with a distance attached to it. So where are we pointing? Down, because we need to reach the floor. What’s the length? Well, that’s REST_LENGTH + WHEEL_RADIUS. Doing some vector math, we get a direction of DOWN * (REST_LENGTH + WHEEL_RADIUS), let’s put that into code.

local function applyForces(wheel: { attachment: Attachment, springForce: VectorForce })
	local down = -wheel.attachment.WorldCFrame.UpVector
	
	local start = wheel.attachment.WorldPosition
	local direction = down * (REST_LENGTH + WHEEL_RADIUS)	
end

Why did I set down to be the negative up vector of the attachment? Well, that’s because down won’t always be (0, -1, 0) the car could be on a slope and now down is like down and to the left.

There is one more thing we need for our raycast and that’s RaycastParams. Since we are starting the raycast pretty close to the body of the car, there’s a chance it could think it hit the body of the car and snap the wheel up. Using RaycastParams we can easily ignore the body of the car.

local function applyForces(wheel: { attachment: Attachment, springForce: VectorForce })
	local down = -wheel.attachment.WorldCFrame.UpVector
	
	local start = wheel.attachment.WorldPosition
	local direction = down * (REST_LENGTH + WHEEL_RADIUS)

	local params = RaycastParams.new()
	params.FilterType = Enum.RaycastFilterType.Exclude
	params.RespectCanCollide = true
	params.FilterDescendantsInstances = {model}
end

Few extra things, RespectCanCollide this is to make sure that the wheel is only driving on parts that are actually collidable, otherwise it may try driving on a wall.
FilterDescendantsInstances is set to {model} not {body} this is because we want to ignore every part in the entire car’s model not just the body.

Now that we have everything we need to do a raycast, let’s do it in code.

local function applyForces(wheel: { attachment: Attachment, springForce: VectorForce })
	local down = -wheel.attachment.WorldCFrame.UpVector
	
	local start = wheel.attachment.WorldPosition
	local direction = down * (REST_LENGTH + WHEEL_RADIUS)

	local params = RaycastParams.new()
	params.FilterType = Enum.RaycastFilterType.Exclude
	params.RespectCanCollide = true
	params.FilterDescendantsInstances = {model}
	
	local result = workspace:Raycast(start, direction, params)
	if result ~= nil then
		local distance = result.Distance
	end
end

The RaycastResult gives us a Distance which is the distance from the start of the ray to the hit. We need displacement though. Displacement again is the distance away from rest length and this does have a sign so above and below would be negative and positive.

We can easily get this by first making sure we are in the center of the wheel, which is where the spring is attached to.

local distance = result.Distance
local length = distance - WHEEL_RADIUS

Then, we simply figure out the offset

local distance = result.Distance
local length = distance - WHEEL_RADIUS
local displacement = REST_LENGTH - length

That’s it!


Now that we have displacement we can finally figure out how much force is needed to be applied. We can call getSpringForce() and it will return our force.

local function applyForces(wheel: { attachment: Attachment, springForce: VectorForce })
	local down = -wheel.attachment.WorldCFrame.UpVector
	
	local start = wheel.attachment.WorldPosition
	local direction = down * (REST_LENGTH + WHEEL_RADIUS)

	local params = RaycastParams.new()
	params.FilterType = Enum.RaycastFilterType.Exclude
	params.RespectCanCollide = true
	params.FilterDescendantsInstances = {model}
	
	local result = workspace:Raycast(start, direction, params)
	if result ~= nil then
		local distance = result.Distance
		local length = distance - WHEEL_RADIUS
		local displacement = REST_LENGTH - length
		
		local springForce = getSpringForce(wheel.attachment.WorldPosition, displacement)
	end
end

Now we can use our VectorForce we created to apply that force onto the car.

local springForce = getSpringForce(wheel.attachment.WorldPosition, displacement)
wheel.springForce.Force = springForce

And we’re done! Or are we? (vsauce) Remember how I mentioned the car going off the ramp. Right now, we our VectorForce will keep applying whatever force its set to, it doesn’t know when the car is off the floor. We need to make sure we reset the force back to (0, 0, 0) otherwise the car will float away. That’s super simple:

local function applyForces(wheel: { attachment: Attachment, springForce: VectorForce })
	local down = -wheel.attachment.WorldCFrame.UpVector
	
	local start = wheel.attachment.WorldPosition
	local direction = down * (REST_LENGTH + WHEEL_RADIUS)

	local params = RaycastParams.new()
	params.FilterType = Enum.RaycastFilterType.Exclude
	params.RespectCanCollide = true
	params.FilterDescendantsInstances = {model}
	
	local result = workspace:Raycast(start, direction, params)
	if result ~= nil then
		local distance = result.Distance
		local length = distance - WHEEL_RADIUS
		local displacement = REST_LENGTH - length
		
		local springForce = getSpringForce(wheel.attachment.WorldPosition, displacement)
		wheel.springForce.Force = springForce
	else
		wheel.springForce.Force = Vector3.zero
	end
end

Now we’re done. That was a lot I know so here’s a quick recap:

  • We built our damped spring equation into code
  • We figured out how to calculate the displacement using raycasts
  • Worked out logically how to find the direction that is needed for the raycast
  • Then used all of that to apply a force using a VectorForce back onto the car

MAKING IT RUN

For the final section, we need to make this actually run. Right now, it’s just a bunch of functions disconnected from each other. We need a worker calling all of these, that worker is RunService

At the top of your code, include RunService local RunService = game:GetService("RunService") then at the very bottom, connect to the Heartbeat

RunService.Heartbeat:Connect(function(dt: number)
	
end)

We are connecting to the Heartbeat because this runs after the physics simulation. This just makes everything more stable. All we need to do here is loop through all the wheels and call applyForces()

RunService.Heartbeat:Connect(function(dt: number)
	for _, wheel in pairs(wheels) do
		applyForces(wheel)
	end
end)

Notice how I’m looping through the wheels table and not model.Wheels:GetChildren()), that’s because we need to have access to the attachment and spring force (without having to search for it).

TESTING & DEBUGGING

You can now hit play and watch as all your hard work… fails immediately, so be the law of a programmer. The car is just stuck on the floor. Don’t panic, let’s get into debugging mode.

First let’s figure out why the car is stuck on the floor. Roblox has a nice handy tool that lets you see constraints, we can toggle this on at Model -> Constraints -> Constraint Details you should be seeing blue arrows at the corners of your car (if not go back up and make sure you didn’t miss a step).

Click on one of the arrows and look at the properties, what is the force? If you are seeing like 30, 40, 60 something very small then that is because our springs aren’t strong enough. They need to be stiffer or in other words, have a larger stiffness. I’ll save you the trouble of tweaking stiffness and damping since I’ve already done it. If you are using the same set up as me, your stiffness should be at 2500 and damping at 250. Yeah, those are a lot bigger than before, you just need to tweak and see what happens.

Now hit play again, you should now see it working right! Nope! The car really wants to smash through the floor. So, again let’s turn on constraint details and see what’s happening. The way the arrow is pointing is the direction the VectorForce is applying the force.

Here it’s pointing down which is completely incorrect, your first thought might be to just negate the direction of the spring force. So, let’s do that and see what happens

local function getSpringForce(worldWheelPos: Vector3, displacement: number): Vector3
	-- F = -kx - cv
	
	local velocity = getVelocityAtPoint(body, worldWheelPos)
	local verticalSpeed = body.CFrame:VectorToWorldSpace(Vector3.yAxis):Dot(velocity)
	
	local springForce = -STIFFNESS * displacement
	local dampingForce = DAMPING * verticalSpeed
	
	local totalForce = springForce - dampingForce
	return -totalForce * Vector3.yAxis
end

So, I negated the totalForce, it’s now -totalForce * Vector3.yAxis instead of totalForce * Vector3.yAxis

Hit play again and watch the car ascend to the heavens or more like bounce to the heavens. Now the spring force isn’t being dampened. So, the problem is not the damping force and not the total force, it’s actually our spring force. Remember how spring force is equal to -kx well, the negative is the problem for us. This is causing the spring force to apply in the wrong direction, simply get rid of that (and remove the negation from total force) and everything should be good.

local function getSpringForce(worldWheelPos: Vector3, displacement: number): Vector3
	-- F = kx - cv
	
	local velocity = getVelocityAtPoint(body, worldWheelPos)
	local verticalSpeed = body.CFrame:VectorToWorldSpace(Vector3.yAxis):Dot(velocity)
	
	local springForce = STIFFNESS * displacement
	local dampingForce = DAMPING * verticalSpeed
	
	local totalForce = springForce - dampingForce
	return totalForce * Vector3.yAxis
end

If everything goes well, it should look like this

You will find how to make the wheels actually follow the car in the advanced section (so it isn’t just a floating brick)

If you don’t like how low it is to the ground, simply increase the REST_LENGTH and it will raise it.

Final Code
local RunService = game:GetService("RunService")

local model = script.Parent
local body = model.Body

local STIFFNESS = 2500
local DAMPING = 250
local REST_LENGTH = 2
local WHEEL_RADIUS = 1.5

local wheels = {}

local function getVelocityAtPoint(part: BasePart, worldPoint: Vector3)
	return part.AssemblyLinearVelocity + part.AssemblyAngularVelocity:Cross(worldPoint - part.Position)
end

local function getSpringForce(worldWheelPos: Vector3, displacement: number): Vector3
	-- F = kx - cv
	
	local velocity = getVelocityAtPoint(body, worldWheelPos)
	local verticalSpeed = body.CFrame:VectorToWorldSpace(Vector3.yAxis):Dot(velocity)
	
	local springForce = STIFFNESS * displacement
	local dampingForce = DAMPING * verticalSpeed
	
	local totalForce = springForce - dampingForce
	return totalForce * Vector3.yAxis
end

local function initWheel(wheel: BasePart)
	wheel.Anchored = false
	wheel.CanQuery = false
	wheel.CanTouch = false
	wheel.CanCollide = false
	
	local attachment = Instance.new("Attachment")
	attachment.Parent = body
	attachment.WorldPosition = wheel.Position
	
	local springVectorForce = Instance.new("VectorForce")
	springVectorForce.Force = Vector3.zero
	springVectorForce.Attachment0 = attachment
	springVectorForce.Parent = attachment
	springVectorForce.Name = "Spring"
	springVectorForce.RelativeTo = Enum.ActuatorRelativeTo.World
	
	table.insert(wheels, {
		attachment = attachment,
		springForce = springVectorForce
	})
end

local function applyForces(wheel: { attachment: Attachment, springForce: VectorForce })
	local down = -wheel.attachment.WorldCFrame.UpVector
	
	local start = wheel.attachment.WorldPosition
	local direction = down * (REST_LENGTH + WHEEL_RADIUS)

	local params = RaycastParams.new()
	params.FilterType = Enum.RaycastFilterType.Exclude
	params.RespectCanCollide = true
	params.FilterDescendantsInstances = {model}
	
	local result = workspace:Raycast(start, direction, params)
	if result ~= nil then
		local distance = result.Distance
		local length = distance - WHEEL_RADIUS
		local displacement = REST_LENGTH - length
		
		local springForce = getSpringForce(wheel.attachment.WorldPosition, displacement)
		wheel.springForce.Force = springForce
	else
		wheel.springForce.Force = Vector3.zero
	end
end

for _, v in pairs(model.Wheels:GetChildren()) do
	if v:IsA("BasePart") then
		initWheel(v)
	end
end

RunService.Heartbeat:Connect(function(dt: number)
	for _, wheel in pairs(wheels) do
		applyForces(wheel)
	end
end)

Due to the body character limit, each tutorial after (either basic or advanced) will have its own message in this post

It’s recommended you go through the tutorials in order, wheel casting is technically the 5th tutorial but in reality it can be added to any tutorial, it’s very standalone

BASICS
II. Wheels Following Suspension
III. Movement & Basic Friction
IV. Steering & Rolling Friction

ADVANCED
V. Wheel CastingWARNING There is an error in this tutorial, the correction can be found here
VI. Client Integration
VII. Traction Control & Advanced Friction: TODO
VIII. Multiplayer Integration: TODO
IX. Multiple Vehicles & Modular Setup: TODO
X. Multiple Bodies: TODO
XI. Optimizations & Using in Real Games: TODO


If there are any typos or mistakes throughout the tutorial, please tell me. And of course, if you need help, don’t be afraid to ask

If you want the .rbxm file, here is it. This includes all the full scripts (with comments) of every tutorial
car tutorial.rbxl (90.2 KB)

90 Likes

WHEELS FOLLOWING SUSPENSION

This section will guide you through making the visual wheels move with the suspension so you can make something that isn’t a hovercraft.

PREREQUISITES:
Suspension


Tutorial

Last time we left off with a floating brick where the wheels just fell off. To get started, we need to figure out how to position the wheel.

So, currently we have the distance from the middle of the car’s base part to the floor. There are two states that could happen: either the raycast hits nothing or we hit something. Let’s start with the simpler of the two, when the raycast hits nothing.

When the raycast hits nothing that means that this wheel isn’t touching the ground, therefore the position can be easily put as just the maximum distance the spring can travel. We already have this, it’s just start + (down * (REST_LENGTH + WHEEL_RADIUS)) just our origin + direction of the ray. But if you look closer, we are trying to position the wheel so if we just took the same direction as the ray we will go too far since positions in Roblox are based on the center. WHEEL_RADIUS takes us too far and puts the center of the wheel on the floor, so we need to get rid of WHEEL_RADIUS to find the where the center of the wheel should go. Therefore, our final position for the wheel is start + (down * REST_LENGTH)

Now the other case is when our raycast hit something, this is a bit tricker but still not bad. Like before we need to figure out where the center of the wheel should be. We know the distance between the car’s base part and the floor (RaycastResult.Distance) so it’s just start + (down * distance)? Close, but remember we need the center of the wheel, this gives us the position to the floor. So just subtract WHEEL_RADIUS, this so happens to be the same as length which is used to calculate displacement so we can just reuse that variable for this.

Now that we have the position of the wheel when touching the floor, start + (down * length), and when not touching the floor, start + (down * REST_LENGTH). We can start doing some programming.

PROGRAMMING

Right now we have the applyForces function which calculates the displacement and the spring force. That’s great when we don’t have wheels but now that we do we need to reuse the RaycastResult for the wheel positioning.

Let’s create a new function called processWheel() which will calculate the displacement and the wheel’s position then call applyForces.

local function applyForces(wheel: { attachment: Attachment, springForce: VectorForce }, displacement: number)
	local springForce = getSpringForce(wheel.attachment.WorldPosition, displacement)
	wheel.springForce.Force = springForce
end

local function processWheel(wheel: { attachment: Attachment, springForce: VectorForce })
	wheel.springForce.Force = Vector3.zero
	
	local down = -wheel.attachment.WorldCFrame.UpVector

	local start = wheel.attachment.WorldPosition
	local direction = down * (REST_LENGTH + WHEEL_RADIUS)

	local params = RaycastParams.new()
	params.FilterType = Enum.RaycastFilterType.Exclude
	params.RespectCanCollide = true
	params.FilterDescendantsInstances = {model}

	local result = workspace:Raycast(start, direction, params)
	if result ~= nil then
		local distance = result.Distance
		local length = distance - WHEEL_RADIUS
		local displacement = REST_LENGTH - length
		
		applyForces(wheel, displacement)
	end
end

Notice how we are resetting the springForce back to 0 at the top of processWheel(). Let’s now calculate the wheel position. We can define a variable with no value that can be set differently depending on what happened.

local function processWheel(wheel: { attachment: Attachment, springForce: VectorForce })
	wheel.springForce.Force = Vector3.zero
	
	local down = -wheel.attachment.WorldCFrame.UpVector

	local start = wheel.attachment.WorldPosition
	local direction = down * (REST_LENGTH + WHEEL_RADIUS)

	local params = RaycastParams.new()
	params.FilterType = Enum.RaycastFilterType.Exclude
	params.RespectCanCollide = true
	params.FilterDescendantsInstances = {model}

	local result = workspace:Raycast(start, direction, params)
	local wheelPos
	
	if result ~= nil then
		local distance = result.Distance
		local length = distance - WHEEL_RADIUS
		local displacement = REST_LENGTH - length
		
		wheelPos = start + down * length
		applyForces(wheel, displacement)
	else
		wheelPos = start + down * REST_LENGTH
	end
end

Pretty simple so far. Now that we know where the wheel should be, we need to actually set the position of the wheel. You’re first thought might be to just set the position of the wheel part. There’s a problem with that though, if you ever use this so players can drive the car, they need network ownership. Simply setting the position of the car’s wheels will cause them to lag behind due to latency reasons.

Instead, we are going to weld the wheel directly to the car’s base part. You probably know welds as something that keeps two objects connected, so moving one will move the other. With that logic you may think, “how will a weld help us?”, the answer is that welds can have offsets. We can set the offset of Part1 to be 50 studs away from Part0 and the weld will keep everything still connected.

Let’s do that in code. We need to go back to the initWheel function to set up the weld. Now at this point, the rotation of our wheel parts is important especially if you are using models that look different depending on how you rotate them. For our case, wheels on the left will have a rotation of 180 on the Y axis (X and Z axes don’t matter), and wheels on the right will be 0 on the Y axis.

local function initWheel(wheel: BasePart)
	wheel.Anchored = false
	wheel.CanQuery = false
	wheel.CanTouch = false
	wheel.CanCollide = false
	
	local attachment = Instance.new("Attachment")
	attachment.Parent = body
	attachment.WorldPosition = wheel.Position
	
	local springVectorForce = Instance.new("VectorForce")
	springVectorForce.Force = Vector3.zero
	springVectorForce.Attachment0 = attachment
	springVectorForce.Parent = attachment
	springVectorForce.Name = "Spring"
	springVectorForce.RelativeTo = Enum.ActuatorRelativeTo.World
	
	local weld = Instance.new("Weld")
	weld.Part0 = body
	weld.Part1 = wheel
	weld.Parent = wheel
	
	if attachment.Position.X < 0 then
		weld.C0 *= CFrame.Angles(0, math.rad(180), 0)
	end
	
	table.insert(wheels, {
		attachment = attachment,
		springForce = springVectorForce,
		weld = weld
	})
end

We use a Weld and not a WeldConstraint since you can’t edit the offset of a WeldConstraint.

Part0 and Part1 don’t really matter but most of the time Part0 is the anchor or the part you want to weld to and Part1 is the welder or the part that is being welded.

There is this small if statement:

if attachment.Position.X < 0 then
	weld.C0 *= CFrame.Angles(0, math.rad(180), 0)
end

This is checking where the wheel is relative to the car’s base part, then adjusting the rotation accordingly. Remember attachment is set to the same position as the wheel and Attachment.Position is the relative position of the attachment. So, (0, 0, 0) would be the part’s center. The X axis determines left and right so we can use this to figure out which wheels are on the left and which are on the right, without checking any names.

weld.C0 is the CFrame offset of Part1, yes the numbers don’t match but I didn’t make it. We are multiplying because that is now CFrame “addition” works.

Lastly, we add it to the wheel’s table that is inserted into wheels so we can access it in processWheel().

Back in processWheel() we can use our new wheelPos to set the offset of the weld to position the wheel correctly. So, let’s just use that position and set the C0.

local function processWheel(wheel: { attachment: Attachment, springForce: VectorForce, weld: Weld })
	wheel.springForce.Force = Vector3.zero
	
	local down = -wheel.attachment.WorldCFrame.UpVector

	local start = wheel.attachment.WorldPosition
	local direction = down * (REST_LENGTH + WHEEL_RADIUS)

	local params = RaycastParams.new()
	params.FilterType = Enum.RaycastFilterType.Exclude
	params.RespectCanCollide = true
	params.FilterDescendantsInstances = {model}

	local result = workspace:Raycast(start, direction, params)
	local wheelPos
	
	if result ~= nil then
		local distance = result.Distance
		local length = distance - WHEEL_RADIUS
		local displacement = REST_LENGTH - length
		
		wheelPos = start + down * length
		applyForces(wheel, displacement)
	else
		wheelPos = start + down * REST_LENGTH
	end
	
	wheel.weld.C0 = CFrame.new(wheelPos) * wheel.weld.C0.Rotation
end

CFrame.new(wheelPos) converts our Vector3 into a CFrame then we multiply by the rotation of the weld to make sure it always has the same rotation.

TESTING & DEBUGGING

Now let’s test it. If you look very closely, you may notice that the car is starting to do backflips, and the wheels are nowhere near it. Let’s go back, remember Weld.C0 is an offset and offset implies that it’s relative. We have wheelPos which isn’t in local space, it’s world space. Using our handy CFrame functions, we can easily convert between local space and world space.

wheel.weld.C0 = body.CFrame:ToObjectSpace(CFrame.new(wheelPos) * wheel.weld.C0.Rotation)

Remember, Weld.C0 is an offset from Part0 so we need to use Part0’s CFrame to get it into local space. Let’s hit play again and see what happens.

Now our wheels are rotating on their own, why is that? Well, we are setting the position of the weld, but we are multiplying by the current rotation and CFrame multiplication is basically the same as addition (when in the same relative space) so we add the current rotation to the current rotation, then add it to the current rotation, and again and again. The wheel just keeps rotating, to fix this we need to give it a constant rotation. We already know what that should be from before (inside our initWheel() function), we can copy that over with some tweaks and we get.

local weldC0 = CFrame.new(wheelPos)
if wheel.attachment.Position.X < 0 then
	weldC0 *= CFrame.Angles(0, math.rad(180), 0)
end
wheel.weld.C0 = body.CFrame:ToObjectSpace(weldC0)

Final thing is, if you try rotating the car, the wheels don’t rotate with it. We can easily fix this by making sure our starting rotation is the same as the car’s body

local wheelRotation = body.CFrame.Rotation
if wheel.attachment.Position.X < 0 then
	wheelRotation *= CFrame.Angles(0, math.rad(180), 0)
end

local weldC0 = CFrame.new(wheelPos) * wheelRotation
wheel.weld.C0 = body.CFrame:ToObjectSpace(weldC0)

Now if you run it, you should get something that looks like this


I added extra parts so you can see the rotation of the wheels

As always, if it’s too low you can increase the REST_LENGTH to raise it.

Full Code
local RunService = game:GetService("RunService")

local model = script.Parent
local body = model.Body

local STIFFNESS = 2500 -- How strong is the spring or how fast will the spring react to movement
local DAMPING = 250 -- How much of the spring's force is being pushed back, this slows down the spring
local REST_LENGTH = 2 -- The distance in studs of how far the spring should travel
local WHEEL_RADIUS = 1.5 -- The distance in studs of the radius of the wheel (this should be equal to half the size of the wheel's part)

local wheels = {}

-- This gets the total linear velocity of the part at that point
-- It does this by adding on the linear velocity, then finding the linear velocity due to angular 
-- 	velocity using the Cross product
local function getVelocityAtPoint(part: BasePart, worldPoint: Vector3)
	return part.AssemblyLinearVelocity + part.AssemblyAngularVelocity:Cross(worldPoint - part.Position)
end

-- This uses the damped spring equation to calculate the force a spring would be applying given the current displacement
local function getSpringForce(worldWheelPos: Vector3, displacement: number): Vector3
	-- F = kx - cv
	
	local velocity = getVelocityAtPoint(body, worldWheelPos)
	local verticalSpeed = body.CFrame:VectorToWorldSpace(Vector3.yAxis):Dot(velocity)
	
	local springForce = STIFFNESS * displacement -- kx
	local dampingForce = DAMPING * verticalSpeed -- cv
	
	local totalForce = springForce - dampingForce
	return totalForce * Vector3.yAxis -- Get the force back into vector form
end

-- This sets up everything needed for the wheel
local function initWheel(wheel: BasePart)
	wheel.Anchored = false
	wheel.CanQuery = false
	wheel.CanTouch = false
	wheel.CanCollide = false
	
	local attachment = Instance.new("Attachment")
	attachment.Parent = body
	attachment.WorldPosition = wheel.Position
	
	local springVectorForce = Instance.new("VectorForce")
	springVectorForce.Force = Vector3.zero
	springVectorForce.Attachment0 = attachment
	springVectorForce.Parent = attachment
	springVectorForce.Name = "Spring"
	springVectorForce.RelativeTo = Enum.ActuatorRelativeTo.World
	
	local weld = Instance.new("Weld")
	weld.Part0 = body
	weld.Part1 = wheel
	weld.Parent = wheel
	
	if attachment.Position.X < 0 then -- This is checking if the wheel is on the left or right side of the car
		weld.C0 *= CFrame.Angles(0, math.rad(180), 0)
	end
	
	table.insert(wheels, {
		attachment = attachment,
		springForce = springVectorForce,
		weld = weld
	})
end

local function applyForces(wheel: { attachment: Attachment, springForce: VectorForce }, displacement: number)
	local springForce = getSpringForce(wheel.attachment.WorldPosition, displacement)
	wheel.springForce.Force = springForce
end

-- This will run for every wheel, it will first calculate the displacement of the spring
-- It will then figure out the wheel's position
-- Then, apply any forces needed by calling applyForces
-- Finally, it will position the wheel
local function processWheel(wheel: { attachment: Attachment, springForce: VectorForce, weld: Weld })
	wheel.springForce.Force = Vector3.zero
	
	local down = -wheel.attachment.WorldCFrame.UpVector

	local start = wheel.attachment.WorldPosition
	local direction = down * (REST_LENGTH + WHEEL_RADIUS)

	local params = RaycastParams.new()
	params.FilterType = Enum.RaycastFilterType.Exclude
	params.RespectCanCollide = true
	params.FilterDescendantsInstances = {model}

	local result = workspace:Raycast(start, direction, params)
	local wheelPos
	
	if result ~= nil then
		local distance = result.Distance
		local length = distance - WHEEL_RADIUS
		local displacement = REST_LENGTH - length
		
		wheelPos = start + down * length
		applyForces(wheel, displacement)
	else
		wheelPos = start + down * REST_LENGTH
	end
	
	-- Make sure the wheel is always rotated with the body
	local wheelRotation = body.CFrame.Rotation
	if wheel.attachment.Position.X < 0 then
		wheelRotation *= CFrame.Angles(0, math.rad(180), 0)
	end
	
	local weldC0 = CFrame.new(wheelPos) * wheelRotation
	wheel.weld.C0 = body.CFrame:ToObjectSpace(weldC0) -- Weld.C0 is relative to Part0 so need to get this into local space
end

for _, v in pairs(model.Wheels:GetChildren()) do
	if v:IsA("BasePart") then
		initWheel(v)
	end
end

RunService.Heartbeat:Connect(function(dt: number)
	for _, wheel in pairs(wheels) do
		processWheel(wheel)
	end
end)
18 Likes

This is pretty swell to see- I know a vast majority of the community is looking for a system with wheels raycasted.

I’d love to see the complete drivable impl if this is ever a thought.

4 Likes

Thank you for contributing to the math side of Roblox lol. I’m sure a lot of people will appreciate your topic of vehicle suspension systems. There are barely any tutorials related to mathematics or physics that make it easy to understand for newcomers. Keep it up

8 Likes

Thanks for the strong and detailed tutorial! I cant wait the other tutorials! :raising_hands:

4 Likes

It’s not the greatest suspension system but you took time to explain everything so anyone new to raycasted car systems can follow along. Great work I hope you will continue on other subjects.

You can tell me a better system??

1 Like

One that you make yourself according to what you need for your, game following a tutorial or downloading a free model to learn how to do it is a nice idea to get started but if you want the best it can’t be one system you just download. It’s more about getting the interactions between everything in your game right. If you just want some things this system could get some improvement on I would say to add tire physics and maybe to add more parameters to the spring, if you’re game is about driving and you prefer smoother gameplay over lag you could even use shapecast. But in the end no matter what I say it won’t matter just have fun make the system you want to play with.

And sorry for all the yap it’s just that a lot of people asked me this kind of question with most thinking they could just download everything from the internet or use AI for it.

That’s the point. Your comment doesn’t fit here, because if I knew how to make a system, I wouldn’t be waiting for people who know about the subject to upload tutorials. Obviously, I’m here because I wouldn’t have a clue how to get started, how to scale up, or how to become advanced. So, that’s why I insisted: if, as you say, there are already systems based on raycast that are good or “better,” please point them out so I can reverse engineer them.

WHEEL CASTING

This section will guide you through making the suspension more accurately follow the shape of the wheels. After this, your suspension will respond sooner to changes in height instead of only responding when directly below the center of the wheel.

PREREQUISITES:
Wheels Following Suspension


Explaination

TRIGONOMETRY REFRESH

Before we get started, let’s review some trigonometry (or introduce it if it’s your first time).

Degrees vs. Radians

Everyone knows degrees, 0 to 360 where 360 is a full circle, radians is the less known version. Both degrees and radians describe an angle, and they can be used interchangeably.

Radians go from 0 to 2π (2 pi or 6.283185). You may notice that radians are a lot smaller than degrees only going to 6.28 instead of 360, so small increases in angles need to be small.

Below is the unit circle, which is a circle of radius 1

Ok, it may look very messy but all we are going to look at is the main coordinates of the XY plane. At 0 degrees or 0 radians, we are on the right side of the circle in the middle. As our angle increases, we go up and to the left.

At 90 degrees or π/2 radians we are at the top middle.
180 degrees or π radians, left middle
270 degrees or 3π/2 radians, bottom middle
Then 360 degrees or 2π radians brings us back to the start.


Sine and Cosine

Now let’s just zoom into the first quadrant

To simplify, I’ve removed the extra lines and numbers. Shown here is a 45-degree angle (π/4 radians) on the unit circle.

Now from trig, we know that any angle can be described by sine and cosine, where cosine is how far to march on the X-axis and sine is how far to march on the Y-axis, shown below

Theta (θ) is the symbol for an angle, usually used for an angle in radians. Now we see that the triangle formed here is a right triangle, from geometry we know that the side lengths of any right triangle can be described like so:
a² + b² = c² where c is the side opposite of the right angle
the Pythagorean Theorem!

Let’s assume we didn’t know what cosθ and sinθ are, we can use the Pythagorean Theorem to figure it out. Our c side is the radius of the circle, in the picture I used r so let’s use r.
The a and b side don’t matter which is which, they just need to be the other two sides, let’s make a = cosθ and b = sinθ.

After plugging in everything we get
(cosθ)² + (sinθ)² = r²

Well that doesn’t give much. So instead let’s use a special right triangle, since this triangle has a 45-degree angle, from geometry we know this is a 45-45-90 triangle. In this special triangle the two sides that aren’t the hypotenuse (side opposite of the right angle) are the same length. Therefore cosθ must equal sinθ in this case, let’s rewrite our equation.

We will replace sinθ and cosθ with a common variable, let’s use x
x² + x² = r²
Since this is the unit circle r is 1, so we get
x² + x² = 1

Using some basic algebra, we solve for x and get x = 1/√2, some more algebra we can rationalize the denominator to give us x = √2/2 which is the form you will normally see it in.


This long-winded explanation is to show that sine and cosine can be used to get the X and Y coordinates of any angle.

Let’s say we have a 45-degree angle and we want to travel 4 units along that angle. Now that we know sin(45) = √2/2 and cos(45) = √2/2 we can easily solve this.

Recall that sine and cosine tell you how far to march on the axes if you were on a unit circle, so we can just take whatever they return and multiply by how far we want to go. So, x = 4cos(45) and y = 4sin(45). This gives us the point (2√2, 2√2).

I’ve been using degrees to explain everything, but when we get to coding math.sin and math.cos expects the angles to be in radians, so you either need to supply the angle in radians or convert the degrees to radians via math.rad

Code under the hood: What is math.rad doing?

rad is short for radians and math.rad is used to convert degrees to radians. As stated before degrees go from 0 to 360 and radians go from 0 to 2π.

0 degrees is the same thing as 0 radians and 360 degrees is the same thing as 2π radians.

Using this we can convert any degree to radians form using simple unit conversation
deg * 2π rad
          ----------
          360 deg

The degrees cancel giving you radians left as your unit. Simplifying the fraction gives you this formula
deg * π rad
          ----------
          180 deg

This is exactly what math.rad is doing


WHAT IS WHEEL CASTING

First thing’s first, I don’t know if wheel casting is the correct term for what I’m describing, but it makes sense after you know what it is so I’m not changing the name.

Wheel casting is where you shoot out a bunch of rays in the shape of the wheel so you can get all the points a wheel could hit something. Before we were only casting one ray directly downwards meaning our suspension will only react whenever a part would collide with the center of the wheel, this causes the car to look super jumpy because it will snap up if the part is a sudden change in height.

The number of rays doesn’t need to be very big before you get smooth movement, around 10 rays is enough.

Just raycasting isn’t enough since now with multiple rays there’s a chance that two rays could return results and now which one should we use? It’s always a good idea to move in the direction of least correction so we want to pick the ray that would result in the least movement which so happens to be the ray with the shortest distance.

Programming

Alright enough math let’s get into the code. This tutorial I’m assuming you have the code that ended with the Wheels Following Suspension tutorial, so that’s where we will pick up.

REFACTORING

To get started, let’s do some refactoring to clean up the code. Right now all of the displacement logic is in the same function as the wheel placement logic. Let’s separate it by creating a new function called calculateDisplacement

local function calculateDisplacement(wheel: { attachment: Attachment })
	local down = -wheel.attachment.WorldCFrame.UpVector

	local start = wheel.attachment.WorldPosition
	local direction = down * (REST_LENGTH + WHEEL_RADIUS)

	local params = RaycastParams.new()
	params.FilterType = Enum.RaycastFilterType.Exclude
	params.RespectCanCollide = true
	params.FilterDescendantsInstances = {model}
	
	local result = workspace:Raycast(start, direction, params)
	local wheelPos = nil
	local displacement = 0

	if result ~= nil then
		local distance = result.Distance
		local length = distance - WHEEL_RADIUS
		wheelPos = start + down * length
		displacement = REST_LENGTH - length
	else
		wheelPos = start + down * REST_LENGTH
	end
	
	return result ~= nil, wheelPos, displacement
end

Let’s use the new function back inside processWheel

local hitSomething, wheelPos, displacement = calculateDisplacement(wheel)
if hitSomething then
	applyForces(wheel, displacement)
else
	wheel.springForce.Force = Vector3.zero
end

WHEEL CAST FUNCTION

That’s refactoring done, let’s create our wheel caster. It would be best if we used a new function, so let’s make doWheelcast with the parameters it needs.

local function doWheelcast(attachment: Attachment, wheelCenter: Vector3, params: RaycastParams)

end

Remember that a wheel cast starts from the center of the wheel, so we are going to need that before wheel casting.

Let’s first define some local constants that allow us to easily edit how the wheel cast will behave. We are going to need to know how many rays to cast, let’s use 10. We also need to know the angle we are casting at; this is tweakable but a good starting point is 180 degrees so we can cover the entire bottom half of the wheel which is usually the place the wheel should be touching anything.

local function doWheelcast(attachment: Attachment, wheelCenter: Vector3, params: RaycastParams)
	local RAY_COUNT = 10
	local CASTING_ANGLE = math.pi -- Recall: sin and cos expect radians
end

We can figure out the angle step by taking our casting angle and dividing that by how many rays we wish to have.

local function doWheelcast(attachment: Attachment, wheelCenter: Vector3, params: RaycastParams)
	local RAY_COUNT = 10
	local CASTING_ANGLE = math.pi
	local ANGLE_STEP = CASTING_ANGLE / RAY_COUNT
end

Ok, now before we get to doing the raycasting we need to figure out how we actually find where to raycast.

Every raycast will start at the center of the wheel, so that’s easy we already have it. Where will the ray end though? Since our wheel is a circle, we can use sine and cosine to figure this out. Since any point on a circle can be described by sine and cosine. We already know our radius of the circle, that’s stored as a constant at the top of the script.

Using that we can find any offset from the center of the circle. The X can be found via cosine and the Y can be found via sine, and we can increase the distance it travels by multiplying by the wheel’s radius.

Remember that wheel casting can yield multiple hit points so we need to store all of our hits in a table that we can find the shortest one from later.

local function doWheelcast(attachment: Attachment, wheelCenter: Vector3, params: RaycastParams)
	local RAY_COUNT = 10
	local CASTING_ANGLE = math.pi
	local ANGLE_STEP = CASTING_ANGLE / RAY_COUNT

	local results = {}
end

Now we could just use wheelCenter as our starting point, but remember the car is allowed to rotate, same as the wheels, so we need to account for that. If we just used wheelCenter, it’s a Vector3 so to add an offset to it, we just add another Vector3. Problem with that is we are moving globally that way meaning our end points will always be in the same position no matter the rotation of the car.

Instead, we can use a CFrame which accounts for rotation. Remember from the wheels following suspension tutorial, we can make sure the rays are rotated with the body by grabbing the body’s rotation and multiplying that by the world CFrame of the wheel’s position, so let’s do that.

local function doWheelcast(attachment: Attachment, wheelCenter: Vector3, params: RaycastParams)
	local RAY_COUNT = 10
	local CASTING_ANGLE = math.pi
	local ANGLE_STEP = CASTING_ANGLE / RAY_COUNT

	local results = {}
	local wheelCenterCF = CFrame.new(wheelCenter) * body.CFrame.Rotation
end

Now it’s time to raycast! We need a way to step along the edge of the circle from an angle of 0 to our CASTING_ANGLE, this is exactly what the for loop is for! We already calculated the step for the for loop so let’s make it.

local function doWheelcast(attachment: Attachment, wheelCenter: Vector3, params: RaycastParams)
	local RAY_COUNT = 10
	local CASTING_ANGLE = math.pi
	local ANGLE_STEP = CASTING_ANGLE / RAY_COUNT

	local results = {}
	local wheelCenterCF = CFrame.new(wheelCenter) * body.CFrame.Rotation
	for i = 0, CASTING_ANGLE, ANGLE_STEP do
		
	end
end

Lua’s for loops are set up like this:
for [VARIABLE] = [START], [END], [STEP]
By default, the step is set to 1 but we can set it to whatever we want, here we used ANGLE_STEP.

Now we need to find our point on the circle, remember that’s just (rcosθ, rsinθ)

local function doWheelcast(attachment: Attachment, wheelCenter: Vector3, params: RaycastParams)
	local RAY_COUNT = 10
	local CASTING_ANGLE = math.pi
	local ANGLE_STEP = CASTING_ANGLE / RAY_COUNT

	local results = {}
	local wheelCenterCF = CFrame.new(wheelCenter) * body.CFrame.Rotation
	for i = 0, CASTING_ANGLE, ANGLE_STEP do
		local x = WHEEL_RADIUS * math.cos(i)
		local y = WHEEL_RADIUS * math.sin(i)
	end
end

i is our angle in this case since it’s starting at 0 and moving to CASTING_ANGLE with the step.

Remember this is an offset from the center of the circle, so all we need to do is add that offset to our wheel’s center. Before we do, remember we need this to rotate with the car’s body, so we need to use wheelCenterCF and move along it. For those who know CFrames, this is done by multiplying by another CFrame

local function doWheelcast(attachment: Attachment, wheelCenter: Vector3, params: RaycastParams)
	local RAY_COUNT = 10
	local CASTING_ANGLE = math.pi
	local ANGLE_STEP = CASTING_ANGLE / RAY_COUNT

	local results = {}
	local wheelCenterCF = CFrame.new(wheelCenter) * body.CFrame.Rotation
	for i = 0, CASTING_ANGLE, ANGLE_STEP do
		local x = WHEEL_RADIUS * math.cos(i)
		local y = WHEEL_RADIUS * math.sin(i)

		local endPos = (wheelCenterCF * CFrame.new(0, y, x)).Position
	end
end

We don’t care about the rotation part of the ending position, so we can ignore it and instantly grab the position.

You may be wondering why x is in the Z-axis of the CFrame, shouldn’t it be in the X-axis? That’s because of how CFrames work. All CFrames have a forward, right, and up vector, in computer graphics the forward vector is usually associated with the Z-axis. Since we are copying the rotation of the body, our wheel’s forward vector is the same as the body’s forward vector. That means when we multiply in the Z-axis we are moving forward, we don’t want to use the X-axis since that’s the right vector and would make the rays not follow the curve of the wheel.

Finally, the only thing that’s left is to raycast and store the result if we hit something.

local function doWheelcast(attachment: Attachment, wheelCenter: Vector3, params: RaycastParams)
	local RAY_COUNT = 10
	local CASTING_ANGLE = math.pi
	local ANGLE_STEP = CASTING_ANGLE / RAY_COUNT

	local results = {}
	local wheelCenterCF = CFrame.new(wheelCenter) * body.CFrame.Rotation
	for i = 0, CASTING_ANGLE, ANGLE_STEP do
		local x = WHEEL_RADIUS * math.cos(i)
		local y = WHEEL_RADIUS * math.sin(i)

		local endPos = (wheelCenterCF * CFrame.new(0, y, x)).Position
		local result = workspace:Raycast(wheelCenter, (endPos - wheelCenter), params)

		if result ~= nil then
			table.insert(results, result)
		end
	end
end

Why (endPos - wheelCenter)? Remember that rays need a direction not an end position. We can easily find the direction by using vector math. A simple way to remember how to get direction from a start and end position is: END - START = DIRECTION FROM START TO END (+ DISTANCE BETWEEN). And since the distance is already embedded into this, we don’t need to multiply by anything.


The last thing we need to do is calculate the new wheel position and displacement. Remember there’s a chance multiple rays could hit so we need to find the shortest one. This is easily done by looping through all the results and comparing the distance to the current shortest distance, if it’s lower then it’s our new shortest ray.

Another case is that none of the rays hit anything, if that’s the case we can’t do much so we break out early and return nothing.

local function doWheelcast(attachment: Attachment, wheelCenter: Vector3, params: RaycastParams)
	local RAY_COUNT = 10
	local CASTING_ANGLE = math.pi
	local ANGLE_STEP = CASTING_ANGLE / RAY_COUNT

	local results = {}
	local wheelCenterCF = CFrame.new(wheelCenter) * body.CFrame.Rotation
	for i = 0, CASTING_ANGLE, ANGLE_STEP do
		local x = WHEEL_RADIUS * math.cos(i)
		local y = WHEEL_RADIUS * math.sin(i)

		local endPos = (wheelCenterCF * CFrame.new(0, y, x)).Position
		local result = workspace:Raycast(wheelCenter, (endPos - wheelCenter), params)

		if result ~= nil then
			table.insert(results, result)
		end
	end

	if #results > 0 then
		local shortestDist = math.huge
		local shortestRay = nil

		for _, v in pairs(results) do
			if v.Distance < shortestDist then
				shortestRay = v
				shortestDist = v.Distance
			end
		end

		if shortestRay ~= nil then

		end
	end
	return nil
end

if shortestRay ~= nil then isn’t fully required since we already made sure there is at least one ray in the table via if #results > 0 then but just in case something went wrong, we make sure.

We also store not just the distance but the ray as well since we need to get the position of it later.

Now, at this point we know we hit something, we need to find the distance from the attachment, that’s the keyword. shortestDist is the distance from the center of the wheel but our spring force is applied relative to the attachment so if we used that distance the displacement would be all messed up. This is really easy, recall that END - START = DIRECTION FROM START TO END (+ DISTANCE BETWEEN), we can extract the distance from this by using .Magnitude

if shortestRay ~= nil then
	local start = attachment.WorldPosition
	local dist = (shortestRay.Position - start).Magnitude
end

Final step is to recalculate the wheel position and displacement, which is exactly the same code as we did before.

if shortestRay ~= nil then
	local start = attachment.WorldPosition
	local dist = (shortestRay.Position - start).Magnitude

	local down = -attachment.WorldCFrame.UpVector
	local length = dist - WHEEL_RADIUS
	local wheelPos = start + down * length
	local displacement = REST_LENGTH - length
	return wheelPos, displacement
end

This concludes our doWheelcast function and we can go back to calculateDisplacement to use it!


APPLYING WHEEL CASTING

Here we let off by just using the single ray to find the wheel position and displacement, let’s instead use the one ray to find the wheel position and also check if we are touching the ground, then use doWheelcast to find the corrected position and displacement

if result ~= nil then
	local distance = result.Distance
	local length = distance - WHEEL_RADIUS
	wheelPos = start + down * length
	
	wheelPos, displacement = doWheelcast(wheel.attachment, wheelPos, params)
	if wheelPos == nil then
		wheelPos = start + down * length
		displacement = REST_LENGTH - length
	end
else
	wheelPos = start + down * REST_LENGTH
end

Since doWheelcast has a chance that it will return nothing, we need to account for that. If it happens, we fallback to just using the one ray to find the position and displacement.


TESTING & DEBUGGING

That’s all the coding done, let’s run it and test!

Running it seems normal, the car isn’t falling through the ground. Use a cylinder to test the wheel casting, slowly move it towards the wheels and see when the wheel reacts, if everything worked it should react the moment the cylinder touches the wheel.

Well shoot, if you do that you see it doesn’t react

Let’s see what’s going on, if we continue to move the cylinder the wheel does indeed response once it reaches the center, so it could be that the wheel caster isn’t hitting anything. Let’s create some parts to show the end position of the rays for the wheel caster to see where they are

for i = 0, CASTING_ANGLE, ANGLE_STEP do
	local x = WHEEL_RADIUS * math.cos(i)
	local y = WHEEL_RADIUS * math.sin(i)
	
	local endPos = (wheelCenterCF * CFrame.new(0, y, x)).Position
	local result = workspace:Raycast(wheelCenter, (endPos - wheelCenter), params)

	local p = Instance.new("Part", workspace)
	p.Size = Vector3.new(0.5, 0.5, 0.5)
	p.Position = endPos
	p.CanQuery = false
	p.CanCollide = false
	p.Anchored = true
	p.Color = Color3.new(1, 0, 0)
	game.Debris:AddItem(p, 0.1)

	if result ~= nil then
		table.insert(results, result)
	end
end

This is debugging, I know I did game.Debris instead of GetService

Let’s run again and see where they are

Well, that’s not right, the rays are on the top half of the wheel instead of the bottom half. Actually, if we look at our code that’s exactly what we told it to do. Remember that angles start at the right middle of a circle, 90 degrees is the top middle, and 180 is left middle.

There’s two ways of fixing this, we could instead start at 180 degrees and go to 360 degrees, or we could rotate the angle by 180 degrees so it’s on the bottom instead. I will show both methods, it’s up to use which one you prefer

Cast 180 degrees to 360 degrees
For this all you need to change is the for loop

for i = math.pi, math.pi + CASTING_ANGLE, ANGLE_STEP do

Rotate angles
For this you only need to change three lines

for i = 0, CASTING_ANGLE, ANGLE_STEP do
	local angle = i + math.pi
	local x = WHEEL_RADIUS * math.cos(angle)
	local y = WHEEL_RADIUS * math.sin(angle)

We add π to the angle to make it flip to the other side.


Testing again and you will see it now works, if everything is correct it should look something like this

Full Code
local RunService = game:GetService("RunService")

local model = script.Parent
local body = model.Body

local STIFFNESS = 2500 -- How strong is the spring or how fast will the spring react to movement
local DAMPING = 250 -- How much of the spring's force is being pushed back, this slows down the spring
local REST_LENGTH = 2 -- The distance in studs of how far the spring should travel
local WHEEL_RADIUS = 1.5 -- The distance in studs of the radius of the wheel (this should be equal to half the largest size of the wheel's part)
local WHEEL_WIDTH = 0.5 -- The distance in studs of the width of the wheel (this should be equal to the smallest size of the wheel's part)

local wheels = {}

-- This gets the total linear velocity of the part at that point
-- It does this by adding on the linear velocity, then finding the linear velocity due to angular 
-- 	velocity using the Cross product
local function getVelocityAtPoint(part: BasePart, worldPoint: Vector3)
	return part.AssemblyLinearVelocity + part.AssemblyAngularVelocity:Cross(worldPoint - part.Position)
end

-- This uses the damped spring equation to calculate the force a spring would be applying given the current displacement
local function getSpringForce(worldWheelPos: Vector3, displacement: number): Vector3
	-- F = kx - cv
	
	local velocity = getVelocityAtPoint(body, worldWheelPos)
	local verticalSpeed = body.CFrame:VectorToWorldSpace(Vector3.yAxis):Dot(velocity)
	
	local springForce = STIFFNESS * displacement -- kx
	local dampingForce = DAMPING * verticalSpeed -- cv
	
	local totalForce = springForce - dampingForce
	return totalForce * Vector3.yAxis -- Get the force back into vector form
end

-- This sets up everything needed for the wheel
local function initWheel(wheel: BasePart)
	wheel.Anchored = false
	wheel.CanQuery = false
	wheel.CanTouch = false
	wheel.CanCollide = false
	
	local attachment = Instance.new("Attachment")
	attachment.Parent = body
	attachment.WorldPosition = wheel.Position
	
	local springVectorForce = Instance.new("VectorForce")
	springVectorForce.Force = Vector3.zero
	springVectorForce.Attachment0 = attachment
	springVectorForce.Parent = attachment
	springVectorForce.Name = "Spring"
	springVectorForce.RelativeTo = Enum.ActuatorRelativeTo.World
	
	local weld = Instance.new("Weld")
	weld.Part0 = body
	weld.Part1 = wheel
	weld.Parent = wheel
	
	if attachment.Position.X < 0 then -- This is checking if the wheel is on the left or right side of the car
		weld.C0 *= CFrame.Angles(0, math.rad(180), 0)
	end
	
	table.insert(wheels, {
		attachment = attachment,
		springForce = springVectorForce,
		weld = weld
	})
end

local function applyForces(wheel: { attachment: Attachment, springForce: VectorForce }, displacement: number)
	local springForce = getSpringForce(wheel.attachment.WorldPosition, displacement)
	wheel.springForce.Force = springForce
end

local function doWheelcast(attachment: Attachment, wheelCenter: Vector3, params: RaycastParams)
	-- raycast 180 degrees (pi radians), from the center to the radius of the wheel
	-- start right of center and move to the left of the center
	
	local RAY_COUNT = 10
	local CASTING_ANGLE = math.pi
	local ANGLE_STEP = CASTING_ANGLE / RAY_COUNT
	
	local results = {}
	local wheelCenterCF = CFrame.new(wheelCenter) * body.CFrame.Rotation -- Rotate the wheel with the body
	for i = 0, CASTING_ANGLE, ANGLE_STEP do
		local angle = i + math.pi -- Rotate the angles 180 degrees to make it face the ground
		local x = WHEEL_RADIUS * math.cos(angle)
		local y = WHEEL_RADIUS * math.sin(angle)
		
		local endPos = (wheelCenterCF * CFrame.new(0, y, x)).Position
		local result = workspace:Raycast(wheelCenter, (endPos - wheelCenter), params)

		if result ~= nil then
			table.insert(results, result)
		end
	end
	
	if #results > 0 then
		local shortestDist = math.huge
		local shortestRay = nil

		for _, v in pairs(results) do
			if v.Distance < shortestDist then
				shortestRay = v
				shortestDist = v.Distance
			end
		end
		
		if shortestRay ~= nil then
			local start = attachment.WorldPosition
			-- We need the distance to be relative to the attachment, not the wheel
			local dist = (shortestRay.Position - start).Magnitude

			local down = -attachment.WorldCFrame.UpVector
			local length = dist - WHEEL_RADIUS
			local wheelPos = start + down * length
			local displacement = REST_LENGTH - length
			return wheelPos, displacement
		end
	end
	return nil
end

local function calculateDisplacement(wheel: { attachment: Attachment })
	local down = -wheel.attachment.WorldCFrame.UpVector

	local start = wheel.attachment.WorldPosition
	local direction = down * (REST_LENGTH + WHEEL_RADIUS)

	local params = RaycastParams.new()
	params.FilterType = Enum.RaycastFilterType.Exclude
	params.RespectCanCollide = true
	params.FilterDescendantsInstances = {model}
	
	local result = workspace:Raycast(start, direction, params)
	local wheelPos = nil
	local displacement = 0

	if result ~= nil then
		local distance = result.Distance
		local length = distance - WHEEL_RADIUS
		wheelPos = start + down * length
		
		wheelPos, displacement = doWheelcast(wheel.attachment, wheelPos, params)
		if wheelPos == nil then
			wheelPos = start + down * length
			displacement = REST_LENGTH - length
		end
	else
		wheelPos = start + down * REST_LENGTH
	end
	
	return result ~= nil, wheelPos, displacement
end

-- This will run for every wheel, it will first calculate the displacement of the spring
-- It will then figure out the wheel's position
-- Then, apply any forces needed by calling applyForces
-- Finally, it will position the wheel
local function processWheel(wheel: { attachment: Attachment, springForce: VectorForce, weld: Weld })
	local hitSomething, wheelPos, displacement = calculateDisplacement(wheel)
	if hitSomething then
		applyForces(wheel, displacement)
	else
		wheel.springForce.Force = Vector3.zero
	end
	
	-- Make sure the wheel is always rotated with the body
	local wheelRotation = body.CFrame.Rotation
	if wheel.attachment.Position.X < 0 then
		wheelRotation *= CFrame.Angles(0, math.rad(180), 0)
	end
	
	local weldC0 = CFrame.new(wheelPos) * wheelRotation
	wheel.weld.C0 = body.CFrame:ToObjectSpace(weldC0) -- Weld.C0 is relative to Part0 so need to get this into local space
end

for _, v in pairs(model.Wheels:GetChildren()) do
	if v:IsA("BasePart") then
		initWheel(v)
	end
end

RunService.Heartbeat:Connect(function(dt: number)
	for _, wheel in pairs(wheels) do
		processWheel(wheel)
	end
end)
How to add the Movement & Basic Friction tutorial

Just follow the tutorial, nothing special needed. View the finished code at the model download on the main post

How to add the Steering & Rolling Friction tutorial

Just follow the tutorial, nothing special needed. View the finished code at the model download on the main post

5 Likes

Sorry for the long wait, I am working on the tutorials for movement and friction, but I wanted to release all of the tutorials around the same time. I just finished the wheel casting tutorial if you want to check that out

3 Likes

You dont get the point, there is no “better system” the better one is the one that fit your game(/and your framework). The only thing i can really tell you here is that if you want something more accurate than raycast use shapecasts except that just go look at how real suspensions work.

MOVEMENT & BASIC FRICTION

This section will guide you through making the vehicle actually be drivable and have friction, so it doesn’t slide around. This will not cover getting a player to control the vehicle, that would be the “Client Integration” tutorial

This section assumes you are coming off of the “Wheels Following Suspension” tutorial. If you are coming from “Wheel Casting”, go to that tutorial’s post after this to view how to add this code to that tutorial

PREREQUISITES:
Wheels Following Suspension

Explanation

The most important thing of any car is the movement. If the car cannot move, it’s not a car.

Two forces make up the movement of a car: the motor force and the friction force. Each play a very important role, I will explain them in detail below.

MOTOR FORCE

Unlike the spring force, the motor force is as bare bones as it gets. No fancy equation needed just simply understanding of how forces work.

The motor force is in charge of making the car move forward or backward. In real life, when a car’s wheel spins on the ground, the wheel pushes backwards on the ground and the ground pushes back making the car move forward (HUGE SIMPLIFICATION). In games, however, that is just way too complicated to simulate, so we cheat it. We do this by figuring out what direction the wheel is facing so we know where forward is. Then all we need then is to figure out how much force to apply in that direction.

Getting the forward direction is important since once you get to the steering tutorial, the motor force needs to rotate with the wheel so you can turn the car. But for now, our forward direction is the same as the front of the car.

To get the actual force we need to apply is done by simply looking at the throttle. The throttle is a number between -1 and 1 where negative numbers mean we are going in reverse. In this tutorial, our throttle will be controlled manually, but in the client integration tutorial you will be controlling it with your keyboard. The throttle isn’t everything we need, we also need to know how strong the engine is, this can be any number you want. Finally, before we can apply the force, we need to make sure the wheel is touching the ground, just like the spring force we don’t want to apply any force if we aren’t touching the ground.

So, we got a motor force now but there’s a small problem. Right now, there is nothing stopping that motor force from just being applied forever meaning the car will keep accelerating forever. We need a way to tell the car to stop applying the force once it reaches a certain speed, this is our maximum speed.

Maximum speed will be measured in studs per second and all we need to do is check the current car’s velocity with the maximum speed, if it above it, stop applying the motor force simply as that.
Or is it…

Yes, we need to measure the car’s velocity with the maximum speed BUT only along the car’s forward direction. The motor force only applies along the forward direction so any velocity that is going against the side of the car shouldn’t count towards the maximum speed. Now the question becomes, what is the velocity along the forward direction?

Remember the Vector3 dot product? It was used in getting the spring force to get our vertical speed, we can do this same thing but for the forward direction.

Instead of clamping though, let’s use a linear drop off so it will slowly stop applying a force instead of hard stopping it, it just makes it smoother.

FRICTION

Ok we have a force that lets the car move but what about if the car was sliding? This is where friction comes in! Friction is a force that opposes movement, is it everywhere even when you aren’t touching anything (that’s air resistance which is a form of friction!).

For this tutorial, we will only be dealing with the friction that happens when two objects touch, specifically when they slide against each other. We will be using the equation for friction, which is very simple:
F = μN

A good way to remember it is, “friction is FuN!” F is our friction force, which is what we want to solve for, so let’s look at the other two.

μ is the Greek letter “mu”, which in this equation is the coefficient of kinetic friction. It’s just a number and in games it can be any number you want. A low μ means friction is very low such as ice and a high μ means friction is very high such as rubber.
In real life, μ changes depending on the material of the two surfaces which are touching. Rubber on dry concrete as a μ of 1 as an example.

There are three forms of friction, static. kinetic (sometimes referred to as dynamic) and rolling. Static friction is the force applied when two non-moving objects and one of them begin sliding against the other. Imagine this as a block on a floor and you pushed it, the instant it started moving a static friction force was applied. Kinetic friction on the other hand is the friction force that is applied while two objects are sliding against each other, static friction is applied once while kinetic is applied many times. Finally, rolling friction opposes movement of an object rolling on a surface without slipping it is a low weaker than the other two types.

Now you may be wondering why we aren’t using rolling friction. That’s because rolling friction is only applied to rolling objects. Yes, our wheels are rolling on the surface, but in this tutorial the friction we are applying is to prevent the car from sliding sideways. In a later tutorial, rolling friction will be added which will cause the car to slowly slow down when no motor force is applied.

All three frictions use the same equation (F = μN) but their coefficient of frictions (μ) are different.

N is the normal force which is the force the wheel is pushing into the ground (inverted). Think of it like this: the harder the wheel is pressed into the ground, the higher the normal force is and therefore more friction can be generated. A say “inverted” because the normal force is always perpendicular to the surface meaning its direction is facing away from the surface.

We already have the normal force; that’s the suspension force we already calculated!


Now there’s a few more things you need to understand before we can actually get to programming.

First, friction depends on velocity. Now that may have just contradicted my entire previous explanation because nowhere in the friction equation does it say it needs velocity. That’s true yet, F = μN is actually the maximum friction force when N = spring force that can be applied. Like said before, friction opposes movement, so we need to oppose the velocity in the sideways direction by figuring out the sideways velocity then flipping it. If that ever exceeds F = μN then we need to clamp it to that, if it gets clamped that means the wheel will start to slide.

Now F is a force, but we currently only have the sideways velocity. We need to convert that velocity into a force to be applied back to oppose the velocity, how do we do that?

F = μN is the max force, so let’s call that Fmax for friction max. Let’s also define our current sideways velocity as S, but we need to so it pushes the other way so -S and let’s call that V

Let’s find our desired friction force. From Newton’s second law we know F = ma (force = mass * acceleration). We also know that a = Δv/Δt (acceleration = change in velocity / change in time). We already have Δv, that’s V since we need to apply a change of -S to counter the friction. Δt is just dt, delta time the change in time since the last frame.

This sounds correct right? Makes sense but actually it wouldn’t work. a = Δv/Δt where Δv is -S (-V) and Δt is dt is saying that we want to apply all of -S in one frame which clearly isn’t right that will cause the car to go flying.

Instead let’s use a safer method. Make the friction force proportional to the sideways velocity, and scale by the car’s mass so heavier cars resist sliding. That gives us Fdesired = -S * mass

Finally, after clamping if we need to, we can apply our force to counter the sliding movement.


REVIEW

That was a lot of talking, here is a quick review.

The motor force pushes the car forward and backward; it comes from the wheel’s forward vector and throttle * engine strength.
The friction force opposes sliding movement where the maximum friction is from F = μN and the desired force is from F = -S * mass and if desired exceeds the max, it is clamped.

That’s enough talking, let’s get into programming!

Programming

Programming time! Everybody’s favorite. Let’s define some constants first.

For friction, we only need one constant MU let’s define that as just 1

local MU = 1

Next, we are going to need the constants for the motor force.

local MU = 1
local ENGINE_POWER = 100
local BRAKING_POWER = 3
local MAX_SPEED = 20
local THROTTLE_DROP_OFF_START = 0.8

BRAKING_POWER is used as a multiplier when the car swaps from moving forward to backwards (throttle changes sign), this makes it, so the car slows down faster and swaps direction sooner.

THROTTLE_DROP_OFF_START is just a percentage of the MAX_SPEED where the motor force will start to drop to zero.

We are also adding onto our script a way to visualize our wheels rotating when moving, for this to happen we need to know the axis to rotation the wheel along.

local ROLL_AXIS = Vector3.xAxis

Finally, we need to know the throttle. Like said in the explanation, we are just manually inputted the throttle so it will be a NumberValue parented to the script

local THROTTLE_VALUE = script.Throttle

Enough constants, let’s define two new functions for our new forces

local function getMotorForce(worldWheelPos: Vector3, throttle: number): Vector3
end

local function getSlidingForce(worldWheelPos: Vector3, suspensionMagnitude: number): Vector3
end

Before we can implement these functions we need to add our new forces to the wheel attachment. So inside initWheel let’s copy and paste the creation of the spring vector force two times to create the friction and motor vector forces.

local frictionVectorForce = Instance.new("VectorForce")
frictionVectorForce.Force = Vector3.zero
frictionVectorForce.Attachment0 = attachment
frictionVectorForce.Parent = attachment
frictionVectorForce.Name = "Friction"
frictionVectorForce.RelativeTo = Enum.ActuatorRelativeTo.World

local motorVectorForce = Instance.new("VectorForce")
motorVectorForce.Force = Vector3.zero
motorVectorForce.Attachment0 = attachment
motorVectorForce.Parent = attachment
motorVectorForce.Name = "Motor"
motorVectorForce.RelativeTo = Enum.ActuatorRelativeTo.World

Then add it to the table

table.insert(wheels, {
	attachment = attachment,
	springForce = springVectorForce,
	frictionForce = frictionVectorForce,
	motorForce = motorVectorForce,
	weld = weld,
	rotation = 0
})

Notice I also added rotation = 0 this is what we will use to track the rotation of the wheel. Speaking of rotation of the wheel, let’s implement that since it’s easy. First we are going to need to know delta time, so update processWheel to have a delta time parameter

local function processWheel(wheel: Wheel, dt: number)

Now where we are calculating the wheel’s rotation, we need to add a section of code to make the wheel rotate based on the current car’s velocity. Well velocity is easy; that’s just the forward speed since that’s the only way the wheel can rotate.

local velocity = getVelocityAtPoint(body, start)
local forwardSpeed = body.CFrame.LookVector:Dot(velocity)

We need to add this to the wheel’s current rotation

wheel.rotation += forwardSpeed * dt * 60

Notice I multiply by dt * 60 this just makes it frame rate independent so no matter how fast or slow the game is running, the wheels will rotate at the same rate. wheel.rotation is in degrees, but wheelRotation needs it in radians, so convert that and add it to the roll axis.

local rotationVector = math.rad(wheel.rotation) * ROLL_AXIS
wheelRotation *= CFrame.Angles(rotationVector.x, rotationVector.y, rotationVector.z)

That’s it for the wheel rotation, simple right?

Last thing we need to do before implementing the new functions, we need to update applyForces so it calls these functions. Nothing much, pretty simple.

local springForce = getSpringForce(wheel.attachment.WorldPosition, displacement)
local frictionForce = getSlidingForce(wheel.attachment.WorldPosition, springForce.Magnitude)
local motorForce = getMotorForce(wheel.attachment.WorldPosition, THROTTLE_VALUE.Value)
	
wheel.springForce.Force = springForce
wheel.frictionForce.Force = frictionForce
wheel.motorForce.Force = motorForce

Now for the fun part! Let’s make these new functions. We are going to start with getSlidingForce first since we have a formula to follow.

Remember:
Fmax = uN
Fdesired = -Sm

So, we need to find the velocity going along the side of the car.

local function getSlidingForce(worldWheelPos: Vector3, suspensionMagnitude: number): Vector3
	local velocity = getVelocityAtPoint(body, worldWheelPos)
	local sidewaysDir = body.CFrame.RightVector
	local sidewaysSpeed = sidewaysDir:Dot(velocity)
end

Now we simply just plug into the formulas

local function getSlidingForce(worldWheelPos: Vector3, suspensionMagnitude: number): Vector3
	local velocity = getVelocityAtPoint(body, worldWheelPos)
	local sidewaysDir = body.CFrame.RightVector
	local sidewaysSpeed = sidewaysDir:Dot(velocity)

	local fMax = MU * suspensionMagnitude
	local fDesired = -sidewaysSpeed * body.AssemblyMass
end

Notice I used body.AssemblyMass this is the mass of the entire car. AssemblyMass checks all connected parts (either through welds or constraints) and adds up their masses into one.

Now, we just need to clamp it and return it

local function getSlidingForce(worldWheelPos: Vector3, suspensionMagnitude: number): Vector3
	local velocity = getVelocityAtPoint(body, worldWheelPos)
	local sidewaysDir = body.CFrame.RightVector
	local sidewaysSpeed = sidewaysDir:Dot(velocity)
	
	local fMax = MU * suspensionMagnitude
	local fDesired = -sidewaysSpeed * body.AssemblyMass
	
	fDesired = math.clamp(fDesired, -fMax, fMax)
	return fDesired * sidewaysDir
end

In the suspension tutorial, I did totalForce * Vector3.yAxis, in this we are multiplying by the local X axis not the global one. This is because the force should be applied in the same direction as the car’s local X. If it was the global X, when the car rotates it will be applying the force not against the sliding movement and therefore wouldn’t be countering anything.


Now comes the harder function, but still not too hard. First, we need to make sure that throttle is within -1 to 1, so clamp it.

local function getMotorForce(worldWheelPos: Vector3, throttle: number): Vector3
	throttle = math.clamp(throttle, -1, 1)
end

Next, we need the forward speed, and this is the same as the sliding force just a different direction.

local function getMotorForce(worldWheelPos: Vector3, throttle: number): Vector3
	throttle = math.clamp(throttle, -1, 1)

	local velocity = getVelocityAtPoint(body, worldWheelPos)
	local forwardDir = body.CFrame.LookVector
	local forwardSpeed = forwardDir:Dot(velocity)
end

For the braking multiplier, we need to see if our throttle is matching the direction of movement. Let’s think about this. Throttle is a number between -1 and 1 where 1 is forward and -1 is backwards. forwardSpeed is a number from -MAX_SPEED to MAX_SPEED where when positive it’s moving forward and negative it’s moving backwards. We have a match up, position numbers are forward, and negative numbers are backwards. We can just check if the signs of throttle and forwardSpeed match to see if we are going the same direction.

A handy function does that for us math.sign

local function getMotorForce(worldWheelPos: Vector3, throttle: number): Vector3
	throttle = math.clamp(throttle, -1, 1)

	local velocity = getVelocityAtPoint(body, worldWheelPos)
	local forwardDir = body.CFrame.LookVector
	local forwardSpeed = forwardDir:Dot(velocity)

	local sameDirection = math.sign(forwardSpeed) == math.sign(throttle)
end

Now we need to find a multiplier to make the motor force halt to zero when it’s approaching MAX_SPEED. First, we need to make sure we are going in the same direction because if we want to swap directions, this multiplier shouldn’t apply. Reason is, if we were going at max speed then decide to go backwards, the motor force will still be zero because we are at max speed.

local function getMotorForce(worldWheelPos: Vector3, throttle: number): Vector3
	throttle = math.clamp(throttle, -1, 1)

	local velocity = getVelocityAtPoint(body, worldWheelPos)
	local forwardDir = body.CFrame.LookVector
	local forwardSpeed = forwardDir:Dot(velocity)

	local sameDirection = math.sign(forwardSpeed) == math.sign(throttle)

	local factor = 1
	if sameDirection then

	end
end

We need to find out what percentage of MAX_SPEED we are; that’s simple. Then we also need to see if we are past THROTTLE_DROP_OFF_START

local function getMotorForce(worldWheelPos: Vector3, throttle: number): Vector3
	throttle = math.clamp(throttle, -1, 1)

	local velocity = getVelocityAtPoint(body, worldWheelPos)
	local forwardDir = body.CFrame.LookVector
	local forwardSpeed = forwardDir:Dot(velocity)

	local sameDirection = math.sign(forwardSpeed) == math.sign(throttle)

	local factor = 1
	if sameDirection then
		local speedRatio = math.abs(forwardSpeed) / MAX_SPEED
		if speedRatio > THROTTLE_DROP_OFF_START then

		end
	end
end

Finally, we need to a linear drop off, which is just a formula

local function getMotorForce(worldWheelPos: Vector3, throttle: number): Vector3
	throttle = math.clamp(throttle, -1, 1)

	local velocity = getVelocityAtPoint(body, worldWheelPos)
	local forwardDir = body.CFrame.LookVector
	local forwardSpeed = forwardDir:Dot(velocity)

	local sameDirection = math.sign(forwardSpeed) == math.sign(throttle)

	local factor = 1
	if sameDirection then
		local speedRatio = math.abs(forwardSpeed) / MAX_SPEED
		if speedRatio > THROTTLE_DROP_OFF_START then
			-- formula for linear drop off
			-- f = clamp(fstart + (fend - fstart) / (xend - xstart) * (x - xstart), fmin, fmax)
			factor = math.clamp(1 - (speedRatio - THROTTLE_DROP_OFF_START) / (1 - THROTTLE_DROP_OFF_START), 0, 1)
		end
	end
end

Now let’s combine everything to calculate the force:

local function getMotorForce(worldWheelPos: Vector3, throttle: number): Vector3
	throttle = math.clamp(throttle, -1, 1)

	local velocity = getVelocityAtPoint(body, worldWheelPos)
	local forwardDir = body.CFrame.LookVector
	local forwardSpeed = forwardDir:Dot(velocity)

	local sameDirection = math.sign(forwardSpeed) == math.sign(throttle)

	local factor = 1
	if sameDirection then
		local speedRatio = math.abs(forwardSpeed) / MAX_SPEED
		if speedRatio > THROTTLE_DROP_OFF_START then
			-- formula for linear drop off
			-- f = clamp(fstart + (fend - fstart) / (xend - xstart) * (x - xstart), fmin, fmax)
			factor = math.clamp(1 - (speedRatio - THROTTLE_DROP_OFF_START) / (1 - THROTTLE_DROP_OFF_START), 0, 1)
		end
	end

	local force = throttle * ENGINE_POWER * factor
	if not sameDirection then
		force *= BRAKING_POWER
	end
	return force * forwardDir
end

Remember from the explanation, our force just equals throttle * engine power, we add onto that * factor so the linear drop off is applied. Then if we are changing direction, we multiply by BRAKING_POWER. Finally, we get it back into vector form when returning.


TESTING & DEBUGGING

You can now run it and try changing the throttle NumberValue and watch it start moving! We got it working!
Or did we…

If you look very closely at the wheels, you will notice that the left wheels are rotating correctly, but the right wheels are rotating the wrong way. Very easy fix to that though, just make the rotation angle negative if the wheel is on the right.

local isLeft = wheel.attachment.Position.X < 0  

local rotationAngle = wheel.rotation
if not isLeft then
	rotationAngle = -rotationAngle
end

local rotationVector = math.rad(rotationAngle) * ROLL_AXIS

Now testing again and you will see the wheels rotating correctly, if everything is correct it should look something like this

Full Code
local RunService = game:GetService("RunService")

local model = script.Parent
local body = model.Body

local THROTTLE_VALUE = script.Throttle -- Number from -1 to 1 which is used for the motor force

local STIFFNESS = 2500 -- How strong is the spring or how fast will the spring react to movement
local DAMPING = 250 -- How much of the spring's force is being pushed back, this slows down the spring
local REST_LENGTH = 2 -- The distance in studs of how far the spring should travel
local WHEEL_RADIUS = 1.5 -- The distance in studs of the radius of the wheel (this should be equal to half the size of the wheel's part)

local MU = 1 -- The coefficient of friction to use in the friction calculation
local ENGINE_POWER = 100 -- How strong the engine is, larger numbers make the car accelerate faster
local BRAKING_POWER = 3 -- How strong the brakes are, this is applied when the throttle changes direction (forward -> backward or backward -> forward)
local MAX_SPEED = 20 -- Maximum speed (in studs per second) that the vehicle can go
local THROTTLE_DROP_OFF_START = 0.8 -- Percentage where of MAX_SPEED where the motor force will start going down to zero

local ROLL_AXIS = Vector3.xAxis -- The axis that is perpendicular to the rolling rotation of the wheels

local wheels = {}

-- This gets the total linear velocity of the part at that point
-- It does this by adding on the linear velocity, then finding the linear velocity due to angular 
-- 	velocity using the Cross product
local function getVelocityAtPoint(part: BasePart, worldPoint: Vector3)
	return part.AssemblyLinearVelocity + part.AssemblyAngularVelocity:Cross(worldPoint - part.Position)
end

-- This uses the damped spring equation to calculate the force a spring would be applying given the current displacement
local function getSpringForce(worldWheelPos: Vector3, displacement: number): Vector3
	-- F = kx - cv
	
	local velocity = getVelocityAtPoint(body, worldWheelPos)
	local verticalSpeed = body.CFrame:VectorToWorldSpace(Vector3.yAxis):Dot(velocity)
	
	local springForce = STIFFNESS * displacement -- kx
	local dampingForce = DAMPING * verticalSpeed -- cv
	
	local totalForce = springForce - dampingForce
	return totalForce * Vector3.yAxis -- Get the force back into vector form
end

-- This uses the throttle (number -1 -> 1) to determine the force to apply in order to get the car moving
local function getMotorForce(worldWheelPos: Vector3, throttle: number): Vector3
	throttle = math.clamp(throttle, -1, 1)
	
	local velocity = getVelocityAtPoint(body, worldWheelPos)
	local forwardDir = body.CFrame.LookVector
	local forwardSpeed = forwardDir:Dot(velocity)
	
	local sameDirection = math.sign(forwardSpeed) == math.sign(throttle)
	
	local factor = 1
	if sameDirection then
		local speedRatio = math.abs(forwardSpeed) / MAX_SPEED
		if speedRatio > THROTTLE_DROP_OFF_START then
			-- formula for linear drop off
			-- f = clamp(fstart + (fend - fstart) / (xend - xstart) * (x - xstart), fmin, fmax)
			factor = math.clamp(1 - (speedRatio - THROTTLE_DROP_OFF_START) / (1 - THROTTLE_DROP_OFF_START), 0, 1)
		end
	end
	
	local force = throttle * ENGINE_POWER * factor
	if not sameDirection then
		force *= BRAKING_POWER -- Make changing directions stronger
	end
	-- We use forwardDir not Vector3.zAxis because we need it to be in the direction of the car, not world
	return force * forwardDir
end

-- This calculates the force to oppose the wheel sliding sideways
local function getSlidingForce(worldWheelPos: Vector3, suspensionMagnitude: number): Vector3
	-- Fmax = uN
	-- Fdesired = -Sm
	
	local velocity = getVelocityAtPoint(body, worldWheelPos)
	local sidewaysDir = body.CFrame.RightVector
	local sidewaysSpeed = sidewaysDir:Dot(velocity)
	
	local fMax = MU * suspensionMagnitude
	local fDesired = -sidewaysSpeed * body.AssemblyMass
	
	-- fDesired can NEVER be bigger than fMax, so clamp it if that's the case
	fDesired = math.clamp(fDesired, -fMax, fMax)
	return fDesired * sidewaysDir
end

type Wheel = {
	attachment: Attachment,
	springForce: VectorForce,
	frictionForce: VectorForce,
	motorForce: VectorForce,
	weld: Weld,
	rotation: number
}

-- This sets up everything needed for the wheel
local function initWheel(wheel: BasePart): Wheel
	wheel.Anchored = false
	wheel.CanQuery = false
	wheel.CanTouch = false
	wheel.CanCollide = false
	
	local attachment = Instance.new("Attachment")
	attachment.Parent = body
	attachment.WorldPosition = wheel.Position
	
	local springVectorForce = Instance.new("VectorForce")
	springVectorForce.Force = Vector3.zero
	springVectorForce.Attachment0 = attachment
	springVectorForce.Parent = attachment
	springVectorForce.Name = "Spring"
	springVectorForce.RelativeTo = Enum.ActuatorRelativeTo.World
	
	local frictionVectorForce = Instance.new("VectorForce")
	frictionVectorForce.Force = Vector3.zero
	frictionVectorForce.Attachment0 = attachment
	frictionVectorForce.Parent = attachment
	frictionVectorForce.Name = "Friction"
	frictionVectorForce.RelativeTo = Enum.ActuatorRelativeTo.World

	local motorVectorForce = Instance.new("VectorForce")
	motorVectorForce.Force = Vector3.zero
	motorVectorForce.Attachment0 = attachment
	motorVectorForce.Parent = attachment
	motorVectorForce.Name = "Motor"
	motorVectorForce.RelativeTo = Enum.ActuatorRelativeTo.World
	
	local weld = Instance.new("Weld")
	weld.Part0 = body
	weld.Part1 = wheel
	weld.Parent = wheel
	
	if attachment.Position.X < 0 then -- This is checking if the wheel is on the left side of the car
		weld.C0 *= CFrame.Angles(0, math.rad(180), 0)
	end
	
	table.insert(wheels, {
		attachment = attachment,
		springForce = springVectorForce,
		frictionForce = frictionVectorForce,
		motorForce = motorVectorForce,
		weld = weld,
		rotation = 0
	})
end

local function applyForces(wheel: Wheel, displacement: number)
	local springForce = getSpringForce(wheel.attachment.WorldPosition, displacement)
	local frictionForce = getSlidingForce(wheel.attachment.WorldPosition, springForce.Magnitude)
	local motorForce = getMotorForce(wheel.attachment.WorldPosition, THROTTLE_VALUE.Value)
	
	wheel.springForce.Force = springForce
	wheel.frictionForce.Force = frictionForce
	wheel.motorForce.Force = motorForce
end

-- This will run for every wheel, it will first calculate the displacement of the spring
-- It will then figure out the wheel's position
-- Then, apply any forces needed by calling applyForces
-- Finally, it will position the wheel
local function processWheel(wheel: Wheel, dt: number)
	wheel.springForce.Force = Vector3.zero
	wheel.frictionForce.Force = Vector3.zero
	wheel.motorForce.Force = Vector3.zero
	
	local down = -wheel.attachment.WorldCFrame.UpVector

	local start = wheel.attachment.WorldPosition
	local direction = down * (REST_LENGTH + WHEEL_RADIUS)

	local params = RaycastParams.new()
	params.FilterType = Enum.RaycastFilterType.Exclude
	params.RespectCanCollide = true
	params.FilterDescendantsInstances = {model}

	local result = workspace:Raycast(start, direction, params)
	local wheelPos
	
	if result ~= nil then
		local distance = result.Distance
		local length = distance - WHEEL_RADIUS
		local displacement = REST_LENGTH - length
		
		wheelPos = start + down * length
		applyForces(wheel, displacement)
	else
		wheelPos = start + down * REST_LENGTH
	end
	
	-- Make sure the wheel is always rotated with the body
	local wheelRotation = body.CFrame.Rotation
	local isLeft = wheel.attachment.Position.X < 0  
	if isLeft then
		wheelRotation *= CFrame.Angles(0, math.rad(180), 0)
	end
	
	-- Make the wheel rotate with movement
	local velocity = getVelocityAtPoint(body, start)
	local forwardSpeed = body.CFrame.LookVector:Dot(velocity)
	wheel.rotation += forwardSpeed * dt * 60

	local rotationAngle = wheel.rotation
	if not isLeft then
		rotationAngle = -rotationAngle
	end

	local rotationVector = math.rad(rotationAngle) * ROLL_AXIS
	wheelRotation *= CFrame.Angles(rotationVector.x, rotationVector.y, rotationVector.z)
	
	local weldC0 = CFrame.new(wheelPos) * wheelRotation
	wheel.weld.C0 = body.CFrame:ToObjectSpace(weldC0) -- Weld.C0 is relative to Part0 so need to get this into local space
end

for _, v in pairs(model.Wheels:GetChildren()) do
	if v:IsA("BasePart") then
		initWheel(v)
	end
end

RunService.Heartbeat:Connect(function(dt: number)
	for _, wheel in pairs(wheels) do
		processWheel(wheel, dt)
	end
end)
4 Likes

@aIpha_mx @COCOLEBAUJU73
I will explain this sort of stuff in the “Optimizations & Using in Real Games” tutorial in more detail. Yes, you should use a system that works for your game. This tutorial is mainly here to help you get started and explain how the math gets translated into code.

The SpringConstraint uses a certain shape casting method and physical collisions in order to work, therefore it’s a lot laggier than raycasting. It may be more accurate (sometimes) but done right, raycasting can be a lot smoother since no collisions are happening. Combine that with the Wheel Casting tutorial and you get a good replacement for spring constraints

2 Likes

Are you not using dt (delta time) at all in your spring physics maths? That’s quite the oversight because the car will only work as expected at whatever framerate you first set up the stiffness values for. Higher framerates will have much stiffer springs and lag will soften the suspensions.

1 Like

The current suspension code is just the bare basics and shouldn’t really be used in a live game. I used just F = kx - cv since it is much easier to explain. A more practical and optimized version will be made in the “Optimizations & Using in Real Games” tutorial

For those who don’t want to want to wait for that tutorial (I know I can be a bit slow), here is the stable version of the getSpringForce function. You will just need to change a few things around, the function needs to be given delta time as an argument

-- This uses the damped spring equation to calculate the force a spring would be applying given the current displacement
local function getSpringForce(worldWheelPos: Vector3, displacement: number, dt: number): Vector3
	-- F = kx - cv

	local velocity = getVelocityAtPoint(body, worldWheelPos)
	local verticalSpeed = body.CFrame:VectorToWorldSpace(Vector3.yAxis):Dot(velocity)

	local springForce = STIFFNESS * displacement -- kx
	
	-- semi implicit Euler method
	local m = body.AssemblyMass
	local dampingForce = (DAMPING / (1 + (DAMPING * dt) / m)) * verticalSpeed 

	local totalForce = springForce - dampingForce
	return totalForce * Vector3.yAxis -- Get the force back into vector form
end

The stable versions for the friction and movement code will be provided in the optimizations tutorial

4 Likes

hi, i know youre still working on the next tutorials but i decided to use your system. i added steering:

local function applyForces(wheel: Wheel, displacement: number, dt: number, forwardDir: Vector3, sidewaysDir: Vector3)
	local springForce = getSpringForce(wheel.attachment.WorldPosition, displacement, dt)
	local frictionForce = getSlidingForce(wheel.attachment.WorldPosition, springForce.Magnitude, sidewaysDir)
	local motorForce = getMotorForce(wheel.attachment.WorldPosition, seat.ThrottleFloat, dt, forwardDir)

	wheel.springForce.Force = springForce
	wheel.frictionForce.Force = frictionForce
	if wheel.isFront == false then
		wheel.motorForce.Force = motorForce
	else
		local steerDir = body.CFrame.RightVector
		wheel.motorForce.Force = motorForce + steerDir
	end
end
[...]
local steerAngle = 0
if wheel.isFront and seat then
	steerAngle = math.clamp(-seat.SteerFloat, -1, 1) * MAX_STEER_ANGLE
end
	
local steerCFrame = body.CFrame * CFrame.Angles(0, steerAngle, 0)
local forwardDir = steerCFrame.LookVector
local sidewaysDir = steerCFrame.RightVector
applyForces(wheel, displacement, dt, forwardDir, sidewaysDir)

im bad at physics so this is really simplistic and has no body roll which makes the car feel floaty so i added this:

local velocity = getVelocityAtPoint(body, start)
local forwardSpeed = body.CFrame.LookVector:Dot(velocity)
local speedRatio = math.abs(forwardSpeed) / MAX_SPEED
	
local factor = math.abs(seat.SteerFloat) * speedRatio
if wheel.attachment.Position.X < 0 and seat.SteerFloat > 0 or wheel.attachment.Position.X > 0 and seat.SteerFloat < 0 then
	displacement -= 0.3 * factor
	if wheel.isFront then
			displacement -= 0.1 * factor
	end
else
	displacement += 0.2 * factor
end

applyForces(wheel, displacement, dt, forwardDir, sidewaysDir)

the constants are maximum spring lengths scaled by the steer input and car speed % and added to/subtracted from the original lengths, it basically allows me to fully control the body roll and results in a pretty believable effect:



i want to ask if this is faster than using a dedicated steering force and “organically” getting body roll, speed matters because my game can have up to 40-50 cars at a time, im gonna use a single server script to control all the server owned cars and network ownership with a client side physics script to reduce latency

edit: im pretty stupid, you dont need to manually add body roll if your mesh has mass. the reason i had no body roll is because the mesh i was using for my car was massless

Hello, I was wondering if the next part of the tutorial is still in the making. I know making these tutorials take time but they’re so useful and rich in information especially in this gate kept topic :smiley:

Yes the steering tutorial is still in the works, it will be done before Christmas

1 Like