Files
tokyo-wangan-racing/src/client/ChassisController.luau
T
jesse c8809efa7b
CI / quality (push) Failing after 47s
fix(client): re-arm chassis when the drive loop is bound to a stale car
Respawning left the car undrivable. CarManager.spawn() destroys the old car
and builds a new one, but the client's Heartbeat connection survived and kept
driving the DESTROYED car's motors. Because heartbeatConn stayed non-nil, the
cold-start watchdog concluded the chassis was already armed and never armed
the new car - torque stayed at the idle brake value and the car never moved.

Track the seat the live loop is bound to (drivingSeat) and treat the loop as
stale when that seat isn't the one we're sitting in, or its car has left the
workspace. On stale, stop and re-arm against the new car.

Verified: after 4 respawn cycles the car accelerates 49 -> 163 studs/s,
travels 273 studs, stays upright at road level.
2026-07-31 16:00:06 -04:00

185 lines
6.4 KiB
Luau

--!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
-- The seat the live heartbeatConn is bound to. On respawn the old car is
-- destroyed and a new one spawned; the connection stays alive but drives the
-- DESTROYED car's motors. Tracking the seat lets the watchdog notice that the
-- loop is stale and re-arm against the new car.
local drivingSeat: VehicleSeat? = 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
drivingSeat = seat
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
drivingSeat = nil
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
-- A live connection bound to a different seat (or to a car that
-- has since been destroyed) is STALE: it keeps driving the old
-- car's motors, so heartbeatConn stays non-nil and the new car
-- never gets armed. Re-arm whenever the bound seat isn't the
-- one we're actually sitting in, or its car left the world.
local stale = heartbeatConn ~= nil
and (drivingSeat ~= seatPart or not seatPart:IsDescendantOf(workspace))
if seated and (not heartbeatConn or stale) then
stopDriving()
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