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.
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.


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.
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)