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


Current Project ) Car | Project File (86.2 KB)


Concluded Project ) No Official Conclusion yet...


Record Keeping
Established Date : 2025-02-21T00:00:00Z
Ended Data : N/A

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.