Files
tokyo-wangan-racing/src/client/ChassisController.luau
T
jesse f70a0b06bb
CI / quality (push) Failing after 47s
fix(client): stop the chassis watchdog dying on a nil seat during respawn
The stale-loop check indexed seatPart without a nil guard:

    not seatPart:IsDescendantOf(workspace)

On respawn CarManager destroys the old car, so humanoid.SeatPart is nil for a
frame while heartbeatConn is still live. That path then indexed a nil value,
the error propagated out of the task.spawn thread, and the watchdog - the only
self-healing path in the controller - DIED PERMANENTLY. The car was undrivable
from then on, and the bug looked intermittent because the edge-triggered
SeatPart signal still covered the simple nil -> seat transition.

Nil-check seatPart before indexing, and wrap the whole watchdog body in pcall
so no future error can ever silently kill the loop again (warn instead).

Verified: 5 consecutive respawns then drives to MaxSpeed 163 studs/s at road
level (up=1.00), TIME_ATTACK sessionKm advances 0.003 -> 0.169 km.
2026-07-31 16:15:55 -04:00

202 lines
7.1 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
-- The whole body is pcall-wrapped: this watchdog is the ONLY
-- self-healing path. If it ever errors the thread dies silently
-- and the car becomes permanently undrivable, with the failure
-- appearing intermittent because the edge-triggered seat signal
-- still handles the easy nil -> seat case. Never let it die.
local ok, err = pcall(function()
local seatPart = humanoid.SeatPart
local seated = seatPart ~= nil
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 sitting in, or it left the world.
-- NOTE: seatPart is nil for a frame whenever the old car is
-- destroyed on respawn, so it MUST be nil-checked before it
-- is indexed -- that crash is what killed this loop before.
local stale = heartbeatConn ~= nil
and (
drivingSeat ~= seatPart
or seatPart == nil
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
end)
if not ok then
warn("[ChassisController] watchdog iteration failed: " .. tostring(err))
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