Trying to create Ray-cast Suspension; Editable, Open, Stable and Optimized

THE TOPIC IS FOCUSING ON RAYCAST SUSPENSION NOT ROBLOX’S SUSPENSION CONSTRAINTS, AND IS AN INDEPENDANT SYSTEM.

This is approximately my 7th time trying to achieve an Editable, Open, Stable and Optimized Ray-cast Suspension, I’ve seen so many developers do this saying its simple which makes me clench my fists and want to absolutely obliterate my setup, but I don’t have the energy nor the ability to replace it. I’ve created this post to work together to solve this problem; Jittering, Flinging, Odd Events, etc.

Before I start packing my bags to go to Godot and becoming an actual Solo Dev, let me give this one more full on try.

:face_with_raised_eyebrow: Why Choose Ray-cast/Independently Created Suspension?

  • Full Customization & Control
  • Optimized Performance (Roblox’s spring constraints has features we do not need most of the time)
  • More Realistic & Versatile Behavior
  • Compatibility & Expandability (unlike Spring Constraints)
  • Easier Debugging & Troubleshooting

:mag: Researched Articles and Resources

Briefly, making suspension In other games does not require much thinking and effort since the Physics engine is quite linear and predictable. For example this is what I would do for a drivable body,

--PSEUDOCODE
VEHICLE_BODY = ...
WHEEL_POINTS = {...} OR [...]

UPDATE:
  FOR EACH WHEEL_POINTS (POINT):
     POINT:ApplyForce(UPVECTOR*STRENGTH*math.clamp(rayDistance/MAXLENGTH, 0, 1)

But much more calculations are involved just for some normal looking suspension in roblox.

Resources I’ve Used for Ray-cast Based Suspension


:stop_sign: The Problems

But for some fudging reason, I need to know copious amounts of rocket science just to make it stably float on a flat surface and nothing else; Its very complicated to achieve pleasing but simple results. Here are some factors/facts and results about it:

  • Impulse Sucks, Vector Forces do nothing but are the best way to go and Body Velocities/Gyros are deprecated (buggy)
  • Friction/Traction Forces on the Right-Vector are not accurate, for whatever reason.
  • Adding other features like changeable dampening breaks the system completely.
  • Suspension Results are either to Upright-ish (not realistic) or very buggy and unrealistic in certain cases, for example your car flipping onto its side and instantly getting back onto its wheels

Visual Examples (Projects I was trying to make)


:construction: Project Build

This is where the research is started and concluded.

Project Overview

Car Hierarchy

Functionality
Functionality

Main Script

--================
--> SERVICES
--================

local RunService = game:GetService("RunService")

--================
--> Set-up
--================
local CarModel = workspace.Car
local Root = CarModel.PrimaryPart or CarModel:WaitForChild("Root")

--================
--> MODULES
--================
local ValueHandler = require(script.ValueHandler)
local SuspensionModule = require(script.SuspensionModule)
local GizmoModule = require(script.GizmoModule)

local Config = ValueHandler.New(CarModel:FindFirstChild("Configuration"))

--=====================
--> OBJECTS AND VALUES
--=====================

local WheelAttachments = Root:GetChildren()

local CachedWheelData = {}
local THRUST_OFFSET_Z = 3
local THRUST_OFFSET_Y = -1
local MIN_LENGTH = 1
local TORQUE = 18

local Velocity = Vector3.zero

-- Properties
local SpringStiffness = Config:GetValue("SpringStiffness")
local SpringDamping = Config:GetValue("SpringDamping")
local SpringLength = Config:GetValue("SpringLength")
local WheelRadius = Config:GetValue("WheelRadius")
local WheelStiffness = Config:GetValue("WheelStiffness")
local WheelFriction = Config:GetValue("WheelFriction")
local WheelDamping = Config:GetValue("WheelDamping")

local Gas = Config:GetValue("Gas")

--=====================
--> FUNCTIONS
--=====================
GizmoModule:Enable()

local function updateProperties()
	SpringStiffness = Config:GetValue("SpringStiffness")
	SpringDamping = Config:GetValue("SpringDamping")
	SpringLength = Config:GetValue("SpringLength")
	WheelRadius = Config:GetValue("WheelRadius")
	WheelStiffness = Config:GetValue("WheelStiffness")
	WheelFriction = Config:GetValue("WheelFriction")
	WheelDamping = Config:GetValue("WheelDamping")
	
	Gas = Config:GetValue("Gas")
end

local function updateWheel(attachment:Attachment, delta)
	
	local WheelRaycast = SuspensionModule.Raycast(attachment.WorldPosition, -attachment.WorldCFrame.UpVector, SpringLength, WheelRadius, attachment.CFrame.LookVector, {CarModel})
	local VectorForce = attachment:FindFirstChild("VectorForce")
	
	local PointCFrame = attachment.WorldCFrame
	local PointPosition = attachment.WorldPosition
	
	local RightVector = PointCFrame.RightVector
	local UpVector = PointCFrame.UpVector
	local FowardVector = PointCFrame.LookVector
	
	if not VectorForce or not WheelRaycast then
		if not WheelRaycast and VectorForce then
			VectorForce.Force = Vector3.zero
		end
		return
	end
	
	local RayDistance = WheelRaycast.Distance
	
	-- Steering/Friction
	local MaxFrictionForce = WheelFriction * WheelRaycast.Normal
	local RightAxisForce = MaxFrictionForce
	
	-- Suspension
	local CompressionRatio = 1-(RayDistance/(SpringLength+WheelRadius))
	local PreviousCompressionRatio = CachedWheelData[attachment.Name] and CachedWheelData[attachment.Name].CompressionRatio or 0
	
	local UpAxisForce = SpringStiffness * CompressionRatio
	
	-- Gas/Thrust/Braking
	
	local FowardAxisForce = 0 -- keeping focus on suspension..
	
	VectorForce.Force = (RightVector * RightAxisForce) + (UpVector * UpAxisForce) + (FowardVector * FowardAxisForce)
	
	CachedWheelData[attachment.Name] = {
		CompressionRatio = CompressionRatio,
		SurfaceImpactPoint = WheelRaycast.Position,
		SurfaceImpactNormal = WheelRaycast.Normal
	}
end

--=====================
--> CONNECTIONS
--=====================

Config.ValueChanged:Connect(function(valueName, newValue)
	updateProperties()
end)

RunService.Heartbeat:Connect(function(delta)
	for _, attachment in WheelAttachments do updateWheel(attachment,delta) end
	
	--local Gas = Config:GetValue("Gas")
	
	local DriveOutput = Gas * TORQUE
	
	local RootCFrame = Root.CFrame
	local RootPosition = Root.Position

	local RightVector = RootCFrame.RightVector
	local UpVector = RootCFrame.UpVector
	local FowardVector = RootCFrame.LookVector
	
	-- Gas/Braking
	Root:ApplyImpulseAtPosition((FowardVector * DriveOutput), Root.Position + (FowardVector * THRUST_OFFSET_Z) + (UpVector * THRUST_OFFSET_Y))
end)

Suspension Module

--================
--> Set-up
--================
local Gizmos = require(script.Parent.GizmoModule)

local suspension_module = {}


--================
--> Raycasting
--================
function suspension_module.Raycast(start : Vector3, direction : Vector3, length:number, wheelRadius:number, foward, ignore:{any}) : RaycastResult
	local raycast_params = RaycastParams.new()
	raycast_params.FilterType = Enum.RaycastFilterType.Exclude
	raycast_params.FilterDescendantsInstances = ignore
	
	local ray_end = direction*length
	local raycast_result = workspace:Raycast(start, direction * (length+wheelRadius), raycast_params)
	
	Gizmos.Point(start, 0.1)
	
	if raycast_result then
		Gizmos.Arrow(start, raycast_result.Position, 0.5)
		
		local WheelCFrame = CFrame.new(raycast_result.Position) * CFrame.new(0,wheelRadius,0) * CFrame.lookAt(start, start+foward).Rotation
		Gizmos.Circle(WheelCFrame, wheelRadius, 0.02)
		return raycast_result
	else
		Gizmos.Point(start+ray_end, 0.1)
		Gizmos.Line(start, start+ray_end, 5)
		
		local WheelCFrame = CFrame.new(start+ray_end) * CFrame.new(0,wheelRadius,0) * CFrame.lookAt(start, start+foward).Rotation
		Gizmos.Circle(WheelCFrame, wheelRadius, 0.02)
	end
	
	return false
end

function suspension_module.SphereCast(start : Vector3, direction : Vector3, length : number, radius : numbers, ignore : {any}) : RaycastResult
	local sphere_cast_params = RaycastParams.new()
	sphere_cast_params.FilterType = Enum.RaycastFilterType.Exclude
	sphere_cast_params.FilterDescendantsInstances = ignore
	
	local sphere_cast_result = workspace:Spherecast(start, radius, direction * length, sphere_cast_params)
	
	if sphere_cast_result then
		return sphere_cast_result
	end
	return false
end

function suspension_module.PartCast(start, direction, length, part, ignore) : RaycastResult
	local part_cast_params = RaycastParams.new()
	part_cast_params.FilterType = Enum.RaycastFilterType.Exclude
	part_cast_params.FilterDescendantsInstances = ignore
	
	local part_cast_result = workspace:Shapecast(part, direction * length, part_cast_params)
	
	if part_cast_result then
		return part_cast_result
	end
	return false
end

return suspension_module


PROJECT CLOSED

Current Project ) Car | Project File (87.5 KB)

Versions


Concluded Project ) Project Closed


Record Keeping
Established Date : 2025-02-21T00:00:00Z
Ended Data : 2025-10-25T00:00:00Z

6 Likes

Welp, I guess no results yet… :sad:

Moving a little forward!

May Help those in need, possibly… This script actually works pretty well! This is a general function so you may need to play around with it.


:toolbox: Spring Function

function calculateSpring(origin:Vector3, to:Vector3, delta:number, properties:PropertiesTable) : (Force | Vector3 , CurrentDisplacement | number)
	local toVector = to - origin
	local displacement = math.max(properties.Length - toVector.Magnitude, 0.1)
	
	local velocity = (displacement - properties.lastDisplacement or 0)/delta
	
	local springForce = properties.Mass * properties.Stiffness * displacement
	local dampeningForce = properties.Mass * (properties.Dampening or 0) * velocity
	local netForce = springForce + dampeningForce
	
	return toVector.Unit * netForce, displacement
end

Properties Table
Mass : Number
Stiffness : Number
Dampening : Number (LastDisplacement cant be nil)
Length : Number
LastDisplacement : Number

Resources
I mostly looked around physical science articles and decided to follow the math instead of my head and resoures from other posts and forums - like i should’ve done before.

Force = Mass * Acceleration

Acceleration = ( Final Velocity - Initial Velocity ) / Time Taken

Therefor can be written as:

F = M * ( U - V / Time )


Concluded Formula

Spring = -displacement * strength

DampenedSpring = spring + ( dampening * ( u - v / time ))


:eye: Preview of Results


Although this is far from suspension and for vehicles.

2 Likes

Hey there, I’m not sure if it’s any use to you but I have a custom spring module that essentially acts exactly like the Roblox SpringConstraint but uses Lua instead. I created it because I wanted to be able to scale physics fidelity based on distance from player, as well as create and control vehicles from the client and ultimately have no replication on the server at all (I think I can design a more efficient replication algorithm for cars). It works by spawning a “Config” object with attributes that represent the SpringConstraint’s properties. From there, it uses those to simulate Hooke’s law forces on physical VectorForce objects (I originally did ApplyImpuse() but I thought VectorForces might do better because of a refresh rate error I will explain next).

Unfortunately, It doesn’t work perfectly. It needs to have access to the internal 240hz physics refresh rate, but the RunService.Stepped function developers get access to only fires at 60hz. The result is this:

streamable

As a result, I think I am forced to develop a raycast-based chassis myself now. I’m sure you’ve already torn apart the white jeep on the surburban map, but that might be a good place to start. The developer of that car found a way around that 240hz issue, but at the cost of realism.

This also might be of use to you:

2 Likes

Analyzing your resource I found some useful information! Thank you for providing your source!


Suspension Force

[ All Forces are applied onto the Wheel’s Centre ]

RELATIVE_VELOCITY_Y = ROOT_TRANSFORM:VectorToObjectSpace(ROOT_VELOCITY).Y

SUSPENSION_FORCE = ROOT_TRANSFORM.UP * ( STRENGTH * (REST_LENGTH - DISTANCE) - DAMPENING * RELATIVE_VELOCITY_Y )

Combined (Best from Results)

SUSPENSION_FORCE = ROOT_TRANSFORM.UP * ( STRENGTH * (REST_LENGTH - DISTANCE) - (DAMPENING * (LAST_COMPRESSION - COMPRESSION/delta))

Rolling Resistance Force

RELATIVE_VELOCITY_Z = ROOT_TRANSFORM:VectorToObjectSpace(ROOT_VELOCITY).Z

RESISTANCE_FORCE = ROOT_TRANSFORM.FOWARD*((RELATIVE_VELOCITY_Z * TOTAL_MASS)*ROLLING_RESISTANCE)

A smart Idea from this developer was to apply friction from the suspension force we calculated which makes so much sense!

Suspension Friction Force

SUSPENSION_FRICTION_FORCE = SUSPENSION_FORCE * AbsVecotor(ROOT:VectorToObjectSpace(Vector3.new(FRICTION, 0, ROLLING_RESISTANCE)))

Wheel Friction Force

RELATIVE_VELOCITY_X = ROOT_TRANSFORM:VectorToObjectSpace(ROOT_VELOCITY).X

FRICTION_FORCE = -ROOT_TRANSFORM.RIGHT*((RELATIVE_VELOCITY_X*TOTAL_MASS)*FRICTION)

I’ll try to apply my findings to the project file, and hope for successful results! As of now we just want to simulate this until we move onto playability, Thank you @bigcrazycarboy!

1 Like

You could try adding physics substepping which will make the suspensions more stable at lower fps and for higher spring stiffness values.

2 Likes

Oh my, yes I’ll look into this. This is a lot of information we need; thank you for this! I’ll update you guys sooner or later, school is in the way.

Project File v0.2 has been uploaded, friction and suspension works a little better now, still needs work see you sooner or later.

1 Like

Hello again, I’ve made progress again but I’ll refrain from posting too much information about the project to keep it secret for the final product! As of now I don’t think I’ll be adding sub stepping as the update loop is connected to a physics process but the process loop works fine too, these will be discovered later on.


Preview Of Results (v1.1.2 | 10/3/2024)

<< UNSTABLE VERSION, I WILL NOT BE PROVIDING THIS FILE >>

5 Likes

:wheel: Simulating Wheel Movement

Someone has asked how I’ve made the wheels move quite “realistically” so I’ll tell you how i achieved this result; This is mostly focusing on how I made them rotate.

Code Snippet

function WheelUpdate(delta, Data)
	local wheel = Wheels:FindFirstChild(Data.Name)
	if not wheel or not wheel.PrimaryPart then return end

	local centre = wheel.PrimaryPart
	local motorjoint = centre.Joint  -- Motor6D for rolling
	local rootJoint = centre.Root    -- Motor6D for positioning/steering
	
	local localTransform = Data._attachment.WorldCFrame:ToObjectSpace(Data._transform)
	local positionOffset = localTransform.Position
	local steerAxis = -math.rad(Data._attachment.Orientation.Y)
	local motorAxis = -Data._total_angle 
	rootJoint.C0 = CFrame.new(0, -positionOffset.Y, 0)
	motorjoint.C1 = CFrame.new(motorjoint.C1.Position) * CFrame.Angles(motorAxis, steerAxis, 0)
end

First of all, The wheel is connected to Two Motor6Ds, I’ve done this so Its much easier to update the wheel correctly without much hassle.

The “Centre” part is connected the root and is meant to act as the Wheel Axis point; its joint in named Root, this Root will be for position in the Y-axis.

Furthermore, another joint named “Joint” is the pivot axis which is connected to the Centre and Part named Wheel which are in the same position. For this joint, It’ll act as the Bearing or Motor of the wheel and for steering.


Calculation

-- Update wheel rotation
		--[[
		local platformVel = contactPart and contactPart:GetVelocityAtPosition(rayResult.Position) or V3_ZERO
		local rawVel = self._base:GetVelocityAtPosition(rayResult.Position)
		local relVel = rawVel - platformVel
		local relVelLocal = pointCFrame:VectorToObjectSpace(relVel)]]
		local forwardVel = relVelLocal.Z

		wheel._angularvelocity = math_lerp(wheel._angularvelocity, forwardVel / wheel.Radius, delta * 5)
		wheel._total_angle += wheel._angularvelocity * delta

However, if its not touching the contact patch/ground it’ll slowly return its variable to zero.

wheel._total_angle += wheel._angularvelocity * delta
wheel._angularvelocity = math_lerp(wheel._angularvelocity, 0, delta * 0.85)

Note that this doesn’t including torque calculations meaning this is solely based of the vehicles velocity.

:toolbox: Other Resources

If you need curves for your vehicles as you don’t want to do the tedious calculations for pacejka/lateral fiction calculations or for others reasons, I urge you to use this plugin. It’s not the best but it gets the job done, and it an upgrade from ROBLOX’s deprecated FloatCurves.

Graph Editor by @tyridge77

The only limitation is that the values only range from 0 to 1. so make sure you divide the input by some reasonable value.


:warning: NOTICE

Due to the slow development, I’ve moved most of my time to other projects unfortunately, this is as much as I can give as it already is such a difficult task to “remake” a Physics simulation. The project is still incomplete so I may not be able to provide much information to Assist you.

What still needs to be complete

  • Complete Wheel Friction calculations.
  • Engine Functionality and Integration (Very Difficult)

Resources that may help you.

4 Likes

I’ve come to the conclusion that simulating the entirety of the suspension and its friction wasn’t really practical, so instead I tried to use as much of roblox’s given instances to make this more approachable.


Instead, we use SpringConstraints, Rods and Parts to simulate the suspension.

Visual Suspension Links

In Studio


What you see here is an early implementation of the system, The tire is completely floating as it acts as the tire’s pressure, the friction is then calculated from individual bristles, and torque forces are acted onto the wheel as a reactive response using the Torque Instance, These forces are summed up to a single VectorForce.

Its implementation is very familiar to the Brush Tire model - Though very unstable


I have to give a huge thank you to @noisecooldeadpool362 for ideas and solutions toward this product.

I will therefore be closing this topic, Thank you guys for all the interest you’ve shown, I will now close this project as its been shifted to Tire Dynamics.

3 Likes

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.