How to Implement Vehicle Mechanics Using Constraints

Despite vehicle games being common on Roblox, I was surprised to see nobody covered how to make something beyond simple cars on the dev forum, so I decided this would be my next project. I’ll teach you how to create performant realistic vehicle physics with smooth controls that feel natural using physics constraints and modular code that accepts vehicle performance attributes to simulate corresponding behavior. Along with this, I’ll provide my implementation that you can use for reference, or learn more from. A basic understanding of luau is recommended for this tutorial.

Car Mechanics Simplified

Vehicles typically have engines that convert energy into rotational force(torque), which is transferred to the wheels to create motion. This process works as follows: the engine rotates a crankshaft to reach a target RPM and torque output(based on gas pedal input), which is connected to a transmission. The transmission uses gears of different sizes to trade RPM for higher torque and vice versa, allowing the engine to operate beyond its static RPM and torque values. Lower gears multiply torque at the cost of RPM, increasing acceleration, while higher gears sacrifice torque to handle greater RPM for higher speeds. More gears means finer control over the torque being delivered and the RPM the engine can handle at any given moment. From the transmission, the rotational energy is passed to the axles, which rotate the wheels. The axle ratio further multiplies torque and reduces RPM. Throughout this process, torque and RPM are inversely related(increasing one decreases the other). Additionally, energy is lost as force is transferred through each component, meaning the torque reaching the wheels is slightly less than what the engine produces, typically around 80%-95% depending on the quality of the parts.
Rear-wheel-drive-powertrain-configuration

Building Vehicles use engines, gearing, and brakes to control movement through their wheels. Despite the complex mechanics behind vehicle movement, the result simple: the engine generates rotational force, which is transferred to the wheels, moving the vehicle forward.

Wheels primarily operate along three axes: forward/backward rotation, left/right rotation, and vertical movement (suspension travel). We use physics constraints to limit wheel movement to these axes. Cylindrical Constraints control rotational behavior, while Spring Constraints handle vertical suspension movement.

Screenshot 2025-03-19 224228 Screenshot 2025-03-19 224216

The mesh type and vehicle composition affect the car’s behavior. If the mass of the wheels are too light compared to the strength of the suspension, you will get jittery behavior. A heavier wheel is more stable and lowers the center of mass of your vehicle, which helps prevent rollover. However, wheels that are too heavy make your suspension stiff. For high speed vehicles, Roblox’s mesh collision detector will get in your way. This is because wheel meshes tend to bounce rather than maintain consistent contact with the ground. Use Roblox’s primitive cylinder to handle wheel physics for smoother behavior, which can later be made invisible for a cleaner look.

Start by disabling collisions for the mesh wheels and assign the physics cylinders to a separate collision group. Create another group for the car and disable its collisions with the wheels. For each wheel, make two attachments: One at the wheel hub, and another at the center of the wheel directly under the wheel hub. If the attachment is not at the center, the wheel will oscillate from the misaligned axis of rotation. When you assign your attachments to your cylindrical constraints, make sure attachment0 is the attachment of the vehicle’s body, your cylindrical constraint shouldn’t operate relative to the wheel. Finally, weld your wheel meshes to the cylinders that represent them.

Screenshot 2025-03-20 125351

Cylindrical constraints will have two motors in this setup: the slider, which controls the wheel’s vertical movement (should be off), and another to rotate the wheels for movement. You can change the slider’s limits to control the vertical travel limits of the wheel. Once your constraints are set up, tune them until they feel right. For suspension, I found that damping force of 10%-20% compared to the stiffness works well.

Controls

Attributes define engine performance, gear ratios, and steering behavior, which we can request for in our code. A module script named Vehicle is used to call the Start function, which requests the vehicle’s attributes and parses the gear ratio string into a list of integers along with the initial orientation of our wheel attachments for steering. We then bind our functions to renderStep loops to run each frame:

function Vehicle:Start(car, wheelMotors, player)
	local function parseGears(attribute)
		local Forward = {}
		local Reverse = {}

		-- Parse the gears
		for gear in string.gmatch(attribute, "[%-%.%d]+") do
			local gearValue = tonumber(gear)
			if gearValue < 0 then table.insert(Reverse, math.abs(gearValue)) -- neg ratio signifies reverse(use absolute value)
			else table.insert(Forward, gearValue) end
		end 

		return {Forward = Forward, Reverse = Reverse} -- Return forward and reverse gears
	end

	-- Get the performance metrics of the car
	GearRatios = parseGears(car:GetAttribute("GearRatios"))
	local Engine = {MaxRPM = car:GetAttribute("MaxEngineRPM"), MaxTorque = car:GetAttribute("MaxEngineTorque")}
	local Axle   = {Ratio = car:GetAttribute("AxleRatio"), TransferEfficiency = car:GetAttribute("TransferEfficiency")}
	local Steer  = {MaxAngle = car:GetAttribute("MaxSteerAngle"), Speed = car:GetAttribute("SteerSpeed")}
	local Wheels = {
		Motors = wheelMotors,
		BrakeForce = car:GetAttribute("BrakeForce"), 
		Radius = car:GetAttribute("WheelRadius"), 
		initOrientations = Vehicle.initOrientations(wheelMotors)
	}
	
	local function handleControls(deltaTime)
		currentSpeed = car.Body.AssemblyLinearVelocity.Magnitude * 0.681818
		local localSpeed = car.Body.AssemblyLinearVelocity:Dot(car.Body.CFrame.lookVector)
		local movingForward = localSpeed > 0 
		Controls.handleInputs(Wheels, Steer, Axle, Engine, GearRatios, currentSpeed, movingForward, deltaTime)
	end

	local function updateCamera(deltaTime)
		VehicleCamera.updateCamera(workspace.CurrentCamera, car.Body, deltaTime)
		Vehicle.UpdateHUD(game.Players.LocalPlayer.PlayerGui, currentSpeed)
	end

	RService:BindToRenderStep("HandleControls", Enum.RenderPriority.First.Value, handleControls)  -- #1: Handle the controls
	RService:BindToRenderStep("UpdateCamera", Enum.RenderPriority.Camera.Value + 1, updateCamera) -- #2: Update camera and HUD
end

The first function thats executed is the controls function. Its responsible for handling user inputs to control the car using it’s attributes. It simulates the transfer of energy from the engine to the wheels using the current gear, and axle ratio multipliers:

function Controls.handleInputs(Wheels, Steer, Axle, Engine, GearRatios, currentSpeed, movingForward, dt)
	local turnInput = 0
	local speedInput = 0
	
	currentGear, currentGearRatio = Controls.shiftGear(
		currentSpeed, Axle, Engine, GearRatios, currentGear, Wheels.Radius * 2, movingForward
	)

	local maxRPM = Engine.MaxRPM / (currentGearRatio * Axle.Ratio) -- Max RPM for this gear 
	local wheelTorque = Engine.MaxTorque * currentGearRatio * Axle.Ratio * Wheels.Radius -- Calculate the torque at the wheels

	if UIS:IsKeyDown(Enum.KeyCode.W) then speedInput =  maxRPM * (2 * math.pi) / 60  end -- Move forward
	if UIS:IsKeyDown(Enum.KeyCode.S) then speedInput = -maxRPM * (2 * math.pi) / 60 end -- Move backward
	if UIS:IsKeyDown(Enum.KeyCode.D) then turnInput  =  Steer.MaxAngle  end -- Turn Right
	if UIS:IsKeyDown(Enum.KeyCode.A) then turnInput  = -Steer.MaxAngle  end -- Turn left
	
	Controls.ApplySteer(Wheels.Motors, turnInput, Steer.Speed, Wheels.initOrientations, dt) -- Target max steer angle with deltaTime
	Controls.ApplyTorque(Wheels.Motors, speedInput, wheelTorque, Axle, Wheels) -- Apply torque to proper wheels

	-- These are at the bottom because the ApplyTorque function will overwrite the braking force
	if UIS:IsKeyDown(Enum.KeyCode.LeftShift) then Controls.ApplyBrake(Wheels.Motors, Wheels.BrakeForce) end     -- Brake all four wheels
	if UIS:IsKeyDown(Enum.KeyCode.Space)     then Controls.ApplyHandBrake(Wheels.Motors, Wheels.BrakeForce) end -- Brake rear wheels
end

We listen for WASD inputs to transfer power to the wheels when W/S is pressed, and steer the wheels when A/D is pressed. Starting with steering, we pass the front two wheel cylindrical constraints (wheelMotors) to a steering function, which rotates its attachments, and in turn, steers the wheels:

function Controls.ApplySteer(wheelMotors, turnInput, steerSpeed, wheelInitOrientations, dt)
	-- Calculate new steer angle and interpolate
	local maxDelta = steerSpeed * dt
	if math.abs(turnInput - currentSteerAngle) <= maxDelta then currentSteerAngle = turnInput
	else currentSteerAngle = currentSteerAngle + math.sign(turnInput - currentSteerAngle) * maxDelta end
	
	-- Steer both wheels to target the max steer angle
	for i = 1, 2 do -- Only steer the front wheels (wheels 1,2)
		local motor = wheelMotors[i]
		if motor and motor.Attachment0 and motor.Attachment1 then
			local initialOrientations = wheelInitOrientations[i]
			if initialOrientations then
				motor.Attachment0.CFrame = initialOrientations.att0 * CFrame.Angles(math.rad(currentSteerAngle), 0, 0)
				motor.Attachment1.CFrame = initialOrientations.att1 * CFrame.Angles(math.rad(currentSteerAngle), 0, 0)
			end
		end
	end
end

Acceleration forward and backwards is a little more complicated. We need to account for gearing in the transmission. In each gear, the engine’s torque is multiplied by the axle ratio and gear ratio. However, the engine’s max RPM is also divided by both. This means at lower gears, the maximum speed will be lower due to higher gear ratios. We can calculate the maximum RPM of the wheels at the current gear, and get the car’s max speed to determine whether we should shift up a gear using this function:

function Controls.calculateMaxSpeed(engineRPM, gearRatio, axleRatio, tireDiameter)
	local maxRPM = engineRPM / (gearRatio * axleRatio)
	return (maxRPM * tireDiameter * math.pi * 60) / 5280
end

Additionally, we get the previous maxSpeed of the gear below to check whether we should shift down. If we reach the maxSpeed, we shift to the next gear. If we reach the previous gear’s max speed, we shift down to the previous gear:

function Controls.shiftGear(currentSpeed, Axle, Engine, GearRatios, currentGear, tireDiameter, isMovingForward)
    -- Check if we are moving forward or backward to see which gear list to use
	local gearList = isMovingForward and GearRatios.Forward or GearRatios.Reverse
	currentGear = math.clamp(currentGear, 1, #gearList)
	currentGearRatio = gearList[currentGear]
	
	-- Calculate current gear's max speed for upshifting, and previous gear's max speed for downshifting
	local maxSpeed = Controls.calculateMaxSpeed(Engine.MaxRPM, currentGearRatio, Axle.Ratio, tireDiameter)
	local previousMaxSpeed =
		(currentGear > 1) and Controls.calculateMaxSpeed(Engine.MaxRPM, gearList[currentGear - 1], Axle.Ratio, tireDiameter) or 0
	-- Shift up or down
	if currentSpeed >= maxSpeed and gearList[currentGear + 1] then currentGear += 1 -- Shift up only if there is a next gear
	elseif currentSpeed <= previousMaxSpeed and currentGear > 1 then currentGear -= 1 end -- Shift down if theres a lower gear
	currentGearRatio = gearList[currentGear] -- Update current gear ratio
	
	return currentGear, currentGearRatio
end

With the proper gear determined, we can now transfer the proper torque to the wheels. However, before we do so, we need to handle one last thing. When you set up your constraints, there will a natural mismatch between the directions of rotation for both of your wheels. This is a simple consequence of geometry, and we can handle it by multiplying the target angular speed by - 1 for wheels on the left.

function Controls.ApplyTorque(wheelMotors, speedInput, wheelTorque, Axle)
	local torqueInput = (speedInput ~= 0) and 1 or 0 
	
	for i, motor in ipairs(wheelMotors) do
		if motor then
			-- Check if the motor's name ends with "L" (left-side wheel)
			local isLeftWheel = string.sub(motor.Name, -1) == "L"
			local directionMultiplier = isLeftWheel and 1 or -1

			-- Apply torque only when there is speed input
			motor.AngularVelocity = speedInput * directionMultiplier
			motor.MotorMaxTorque = wheelTorque * Axle.TransferEfficiency * torqueInput
		end
	end
end

For brakes, we loop over all wheels and set the target angular velocity of the motors to 0 using a braking force(usually a very high torque force). To initiate a drift(handbrake), we simply need to make the rear wheels lose grip so that the rear end of the car can slide around. Here’s how both look:

function Controls.ApplyBrake(wheelMotors, brakeForce)
	for _, motor in ipairs(wheelMotors) do
		motor.MotorMaxTorque = brakeForce
		motor.AngularVelocity = 0
	end
end

function Controls.ApplyHandBrake(wheelMotors, brakeForce)
	for i = 3, 4 do  -- Only rear wheels
		local motor = wheelMotors[i]
		motor.MotorMaxTorque = brakeForce
		motor.AngularVelocity = 0
	end
end

So, to summarize, we listen for WASD inputs to steer towards the max steering angle and apply torque to the wheels based on the engine’s torque output, current gear ratio, and axle ratio. Additionally, we check for shift or space inputs to apply brakes(either to all wheels or just the rear wheels for drifting) by setting a high torque value to target zero angular velocity. This setup means the cylindrical constraints target the max RPM in the current gear when accelerating, and 0 MPH when braking.

Surprisingly, thats really all we need to make a good handling car. Roblox’s friction, and physics engine does the rest for us. Now, admittedly, the way we gear up and down isn’t too realistic, as well as the engine performance, which we assume is always at max power without regard for torque and rpm curves. However, I wasn’t trying to have complete realism in this project.

In my implementation, I try to proportionally scale my forces and performance to the real life counterparts of the vehicles im trying to simulate. But I ran into trouble with the wheel weight. I typically had to make it double its IRL weight to stop it from bugging out. But what’s nice is that you can actually just double the torque of the engine to balance it out, and if your trying to scale things proportionally, that doesn’t violate anything.

A final comment for 4WD, or AWD vehicles: accelerating or decelerating shifts the vehicle’s weight forward or backward, creating a grip imbalance between the front and rear wheels. This can cause the vehicle to slide when the speed changes really quickly, because the front and rear wheels rotate at different speeds. Normally, this is handled with differentials that balance torque distribution, giving more power to the wheels with greater grip, but I didn’t implement this because it adds complexity to fix a minor issue with vehicles that accelerate fast. Anyways, here’s the project if you want to test it out for yourself: Vehicle Mechanics.rbxl (1008.9 KB). Huge map warning(2048x200x2048)

13 Likes

This is a very well documented resource and im suprised this hasn’t recieved more recognition. Physics on the platform are so underutilized and something like this is a perfect example of what can be made. Thank you for your time and contribution!

1 Like

Just curious if you’ve at Roblox’s default Racing Template in Studio. It has expandable, configurable physics driven vehicles.

1 Like

Thank you for telling me. I just checked it out, you could totally work with that instead if you dont want to make your own vehicle physics :+1: