--!strict --[[ ChassisController.luau (CLIENT) Drives the constraint chassis while the local player is in a DriveSeat. Why client-side: the server gives the driver network ownership of the car, so physics (and constraint targets) run authoritatively on THIS client — that's what makes driving feel responsive instead of laggy. The server still owns WHICH car you get (allowlist etc.); this module only turns seat input into motor/servo targets. Loop per Heartbeat while seated: Steer -> front servo TargetAngle (speed-sensitive: less lock when fast) Throttle-> wheel motor AngularVelocity + MotorMaxTorque No input-> brake torque toward zero angular velocity ]] local Players = game:GetService("Players") local RunService = game:GetService("RunService") local ReplicatedStorage = game:GetService("ReplicatedStorage") local Shared = ReplicatedStorage.Shared local Cars = require(Shared.Cars) local Chassis = require(Shared.Chassis) local player = Players.LocalPlayer local ChassisController = {} local heartbeatConn: RBXScriptConnection? = nil local seatedConn: RBXScriptConnection? = nil local function findCarModel(seat: VehicleSeat): Model? local model = seat:FindFirstAncestorOfClass("Model") if model and model:GetAttribute("CarId") ~= nil then return model end return nil end local function collectConstraints(model: Model): ({ HingeConstraint }, { HingeConstraint }) local motors: { HingeConstraint } = {} local servos: { HingeConstraint } = {} for _, desc in ipairs(model:GetDescendants()) do if desc:IsA("HingeConstraint") then if desc.Name == Chassis.WHEEL_MOTOR_NAME then table.insert(motors, desc) elseif desc.Name == Chassis.STEER_SERVO_NAME then table.insert(servos, desc) end end end return motors, servos end local function startDriving(seat: VehicleSeat) local model = findCarModel(seat) if not model then return end local carId = model:GetAttribute("CarId") :: string local car = Cars.Get(carId) if not car then return end local tuning = Chassis.tuningFor(car) local motors, servos = collectConstraints(model) local body = model.PrimaryPart if #motors == 0 or not body then return end heartbeatConn = RunService.Heartbeat:Connect(function() local throttle = seat.ThrottleFloat local steer = seat.SteerFloat -- Steering: speed-sensitive — at top speed only ~40% of full lock, -- so the car doesn't spin out from a tap of the key at 150 studs/s. local speed = body.AssemblyLinearVelocity.Magnitude local speedFrac = math.clamp(speed / tuning.MaxSpeed, 0, 1) local lockScale = 1 - 0.6 * speedFrac local targetAngle = Chassis.STEER_SIGN * steer * tuning.MaxSteerAngle * lockScale for _, servo in ipairs(servos) do servo.TargetAngle = targetAngle end -- Drive/brake: if math.abs(throttle) > 0.05 then local targetVel = Chassis.DRIVE_SIGN * throttle * tuning.MaxWheelAngularVelocity for _, motor in ipairs(motors) do motor.AngularVelocity = targetVel motor.MotorMaxTorque = tuning.MotorMaxTorque end else -- Engine braking: drive wheel speed toward zero with brake torque. for _, motor in ipairs(motors) do motor.AngularVelocity = 0 motor.MotorMaxTorque = tuning.BrakeTorque end end end) end local function stopDriving() if heartbeatConn then heartbeatConn:Disconnect() heartbeatConn = nil end end function ChassisController.init() -- SeatPart changes whenever the humanoid sits down or gets up. local function onCharacter(character: Model) local humanoid = character:WaitForChild("Humanoid") :: Humanoid if seatedConn then seatedConn:Disconnect() end local function applySeat() stopDriving() local seatPart = humanoid.SeatPart if seatPart and seatPart:IsA("VehicleSeat") and seatPart.Name == Chassis.SEAT_NAME then startDriving(seatPart) end end seatedConn = humanoid:GetPropertyChangedSignal("SeatPart"):Connect(applySeat) -- WATCHDOG: the SeatPart signal alone is NOT reliable enough to arm the -- chassis. Observed on cold boot: humanoid is Seated in a valid DriveSeat, -- the car is network-owned by us, and the catch-up applySeat() below has -- already run — yet the Heartbeat loop is not connected, so every motor -- keeps MotorMaxTorque = 0 and the car is completely undrivable. The -- signal fires during client bootstrap while modules are still being -- required, and the edge is simply lost; re-running applySeat() once at -- init doesn't help when the seat is assigned a moment AFTER that call. -- A level-triggered check is immune to every ordering of these events: -- if we are seated in a DriveSeat but have no live loop, arm it. task.spawn(function() while humanoid.Parent do local seatPart = humanoid.SeatPart local seated = seatPart and seatPart:IsA("VehicleSeat") and seatPart.Name == Chassis.SEAT_NAME if seated and not heartbeatConn then startDriving(seatPart :: VehicleSeat) elseif not seated and heartbeatConn then stopDriving() end task.wait(0.5) end end) -- RACE FIX: the server can seat the player in a spawned car BEFORE this -- listener exists (CarManager sits them during spawn, which may complete -- before the client finishes requiring its modules). In that case the -- SeatPart signal already fired with nobody listening, so startDriving -- never ran and the motors kept MotorMaxTorque = 0 — the car looked -- completely dead to the player. Evaluate the CURRENT seat state once at -- init instead of only reacting to future changes. applySeat() end if player.Character then onCharacter(player.Character) end player.CharacterAdded:Connect(onCharacter) end return ChassisController