M1: constraint chassis (springs, servo steering, motor wheels)
CI / quality (push) Successful in 54s

- New shared Chassis.luau: geometry constants + per-car tuning from stats
- CarManager.buildCarModel rebuilt: prismatic+spring suspension per corner,
  servo-hinge steering on front knuckles, motor-hinge driven wheels,
  hidden dense ballast for anti-roll; VehicleSeat kept as input device only
- New client ChassisController: drives motors/servos from seat
  ThrottleFloat/SteerFloat per Heartbeat; speed-sensitive steering lock;
  engine braking on throttle release
- Driver gets network ownership of every unanchored part (constraint
  assemblies are separate; missing one causes stuttery physics)
- Docs: MESH_SWAP.md rewritten for shell-on-chassis workflow; ROADMAP updated
This commit is contained in:
2026-07-22 00:20:59 -04:00
parent 07a67204ca
commit b3a1044df6
6 changed files with 482 additions and 97 deletions
+133
View File
@@ -0,0 +1,133 @@
--!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
seatedConn = humanoid:GetPropertyChangedSignal("SeatPart"):Connect(function()
stopDriving()
local seatPart = humanoid.SeatPart
if seatPart and seatPart:IsA("VehicleSeat") and seatPart.Name == Chassis.SEAT_NAME then
startDriving(seatPart)
end
end)
end
if player.Character then
onCharacter(player.Character)
end
player.CharacterAdded:Connect(onCharacter)
end
return ChassisController
+3
View File
@@ -15,6 +15,9 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Shared = ReplicatedStorage.Shared
local Remotes = require(Shared.Remotes)
local Cars = require(Shared.Cars)
local ChassisController = require(script.ChassisController)
ChassisController.init()
local player = Players.LocalPlayer
local playerGui = player:WaitForChild("PlayerGui")
+218 -47
View File
@@ -1,14 +1,23 @@
--!strict
--[[
CarManager.luau (SERVER)
Spawns the player's equipped car as a simple, PROVEN drivable vehicle.
Spawns the player's equipped car as a CONSTRAINT CHASSIS vehicle.
Design choice (per "don't reinvent the wheel"): instead of a hand-rolled
physics chassis, the MVP car is a single driving Part + a VehicleSeat.
Roblox's VehicleSeat drives the assembly via its built-in Throttle/Steer —
the most reliable, least-buggy way to get a car moving on day one. When you
later import the real RX-7 mesh + a constraint chassis (A-Chassis style),
you swap out `buildCarModel` and keep everything else.
M1 upgrade: the old single-Part + VehicleSeat arcade car is replaced with a
proper physical chassis (A-Chassis style, built from Roblox constraints):
Body ──Prismatic(vertical)+Spring──> Hub ──[front only: Servo hinge]──>
Knuckle ──Motor hinge──> Wheel
- Springs give real suspension; prismatics constrain travel to vertical.
- Front knuckles yaw on Servo hinges (steering).
- Every wheel spins on a Motor hinge (drive/brake).
- The VehicleSeat is kept ONLY as the input device: its Throttle/Steer
are read by the client ChassisController, which sets motor/servo
targets locally (the driver owns the physics, so it replicates).
The real RX-7 mesh swap (docs/MESH_SWAP.md) welds a MeshPart onto the Body
and hides the placeholder blocks — the chassis itself doesn't change.
]]
local Players = game:GetService("Players")
@@ -17,68 +26,226 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Shared = ReplicatedStorage.Shared
local Cars = require(Shared.Cars)
local Chassis = require(Shared.Chassis)
local GameConfig = require(Shared.GameConfig)
local DataManager = require(script.Parent.DataManager)
type Car = Cars.Car
type Tuning = Chassis.Tuning
local CarManager = {}
local activeCars: { [Player]: Model } = {}
local carsFolder: Folder
-- Convert a car's 0-100 stats into VehicleSeat tuning ------------------------
local function computeTuning(car: Car)
local maxSpeed = 60 + car.Stats.Speed * 1.6 -- studs/sec-ish target
local torque = 8000 + car.Stats.Acceleration * 220
local turnSpeed = 20 + car.Stats.Handling * 0.35
return maxSpeed, torque, turnSpeed
-- Helpers ---------------------------------------------------------------------
local function makeAttachment(parent: BasePart, name: string, cf: CFrame): Attachment
local att = Instance.new("Attachment")
att.Name = name
att.CFrame = cf
att.Parent = parent
return att
end
-- Build the placeholder car model. Returns the Model with a VehicleSeat.
local function buildCarModel(car: Car, maxSpeed: number, torque: number, turnSpeed: number): Model
-- Attachment whose primary (X) axis points straight up — used for vertical
-- prismatic travel and for the steering servo's yaw axis.
local VERTICAL_AXIS = CFrame.Angles(0, 0, math.rad(90))
local function makePart(props: {
Name: string,
Size: Vector3,
CFrame: CFrame,
Color: Color3?,
Material: Enum.Material?,
CanCollide: boolean,
Parent: Instance,
Shape: Enum.PartType?,
Friction: number?,
}): Part
local part = Instance.new("Part")
part.Name = props.Name
part.Size = props.Size
part.CFrame = props.CFrame
if props.Shape then
part.Shape = props.Shape
end
part.Color = props.Color or Color3.new(0.2, 0.2, 0.2)
part.Material = props.Material or Enum.Material.SmoothPlastic
part.CanCollide = props.CanCollide
if props.Friction then
part.CustomPhysicalProperties = PhysicalProperties.new(0.9, props.Friction, 0.2, 1, 1)
end
part.Parent = props.Parent
return part
end
-- Build one corner of the suspension. `isFront` corners get a steering servo.
-- All positions are in BODY-LOCAL space, converted via body.CFrame.
local function buildCorner(model: Model, body: Part, tuning: Tuning, xSign: number, zSign: number)
local isFront = zSign < 0 -- car forward is -Z
local cornerName = (isFront and "F" or "R") .. (xSign < 0 and "L" or "R")
local wheelLocal = Vector3.new(xSign * Chassis.TRACK_HALF, -Chassis.SUSPENSION_DROP, zSign * Chassis.WHEELBASE_HALF)
local wheelWorld = body.CFrame * CFrame.new(wheelLocal)
-- Hub: rides the prismatic+spring below the body corner ---------------------
local hub = makePart({
Name = "Hub_" .. cornerName,
Size = Vector3.new(0.6, 0.6, 0.6),
CFrame = wheelWorld,
CanCollide = false,
Parent = model,
})
local bodyAtt = makeAttachment(body, "SuspAtt_" .. cornerName, CFrame.new(wheelLocal) * VERTICAL_AXIS)
local hubAtt = makeAttachment(hub, "HubAtt", VERTICAL_AXIS)
local prismatic = Instance.new("PrismaticConstraint")
prismatic.Name = "SuspSlide_" .. cornerName
prismatic.Attachment0 = bodyAtt
prismatic.Attachment1 = hubAtt
prismatic.LimitsEnabled = true
prismatic.LowerLimit = -Chassis.SUSPENSION_TRAVEL / 2
prismatic.UpperLimit = Chassis.SUSPENSION_TRAVEL / 2
prismatic.Parent = body
local spring = Instance.new("SpringConstraint")
spring.Name = "SuspSpring_" .. cornerName
spring.Attachment0 = bodyAtt
spring.Attachment1 = hubAtt
spring.FreeLength = 0.2
spring.Stiffness = tuning.SpringStiffness
spring.Damping = tuning.SpringDamping
spring.LimitsEnabled = false
spring.Parent = body
-- Front corners: a knuckle that yaws on a servo hinge (steering) ------------
local wheelParent: Part = hub
if isFront then
local knuckle = makePart({
Name = "Knuckle_" .. cornerName,
Size = Vector3.new(0.5, 0.5, 0.5),
CFrame = wheelWorld,
CanCollide = false,
Parent = model,
})
local hubSteerAtt = makeAttachment(hub, "SteerAtt", VERTICAL_AXIS)
local knuckleAtt = makeAttachment(knuckle, "KnuckleAtt", VERTICAL_AXIS)
local servo = Instance.new("HingeConstraint")
servo.Name = Chassis.STEER_SERVO_NAME
servo.Attachment0 = hubSteerAtt
servo.Attachment1 = knuckleAtt
servo.ActuatorType = Enum.ActuatorType.Servo
servo.ServoMaxTorque = 1e6
servo.AngularSpeed = tuning.SteerSpeed
servo.TargetAngle = 0
servo.LimitsEnabled = true
servo.LowerAngle = -tuning.MaxSteerAngle
servo.UpperAngle = tuning.MaxSteerAngle
servo.Parent = hub
wheelParent = knuckle
end
-- Wheel: cylinder spinning on a motor hinge about the lateral (X) axis ------
local wheel = makePart({
Name = "Wheel_" .. cornerName,
Size = Vector3.new(Chassis.WHEEL_WIDTH, Chassis.WHEEL_RADIUS * 2, Chassis.WHEEL_RADIUS * 2),
CFrame = wheelWorld,
Shape = Enum.PartType.Cylinder, -- cylinder axis = X = lateral. correct spin axis
Color = Color3.new(0.08, 0.08, 0.09),
Material = Enum.Material.Rubber,
CanCollide = true,
Friction = tuning.WheelFriction,
Parent = model,
})
local parentAtt = makeAttachment(wheelParent, "WheelAxleAtt", CFrame.new())
local wheelAtt = makeAttachment(wheel, "WheelAtt", CFrame.new())
local motor = Instance.new("HingeConstraint")
motor.Name = Chassis.WHEEL_MOTOR_NAME
motor.Attachment0 = parentAtt
motor.Attachment1 = wheelAtt
motor.ActuatorType = Enum.ActuatorType.Motor
motor.MotorMaxTorque = 0 -- freewheel until the ChassisController takes over
motor.AngularVelocity = 0
motor.Parent = wheelParent
end
-- Build the full constraint-chassis car model.
local function buildCarModel(car: Car, tuning: Tuning): Model
local model = Instance.new("Model")
model.Name = "Car_" .. car.Id
-- Body (the driving part) --------------------------------------------------
local body = Instance.new("Part")
body.Name = "Body"
body.Size = Vector3.new(6, 2, 12)
body.Color = Color3.new(car.Color[1], car.Color[2], car.Color[3])
body.Material = Enum.Material.SmoothPlastic
body.CanCollide = true
body.Parent = model
local origin = CFrame.new() -- built at origin; PivotTo() places it later
-- A cabin block so it reads as a car, welded to the body -------------------
local cabin = Instance.new("Part")
cabin.Name = "Cabin"
cabin.Size = Vector3.new(5, 1.6, 5)
cabin.Color = Color3.new(car.Color[1] * 0.7, car.Color[2] * 0.7, car.Color[3] * 0.7)
cabin.Material = Enum.Material.Glass
cabin.CanCollide = false
cabin.Parent = model
-- Body (PrimaryPart / mesh anchor point) ------------------------------------
local body = makePart({
Name = "Body",
Size = Chassis.BODY_SIZE,
CFrame = origin,
Color = Color3.new(car.Color[1], car.Color[2], car.Color[3]),
CanCollide = true,
Parent = model,
})
-- Ballast: dense low block welded under the body — drops the centre of mass
-- so the car corners flat instead of rolling over.
local ballast = makePart({
Name = "Ballast",
Size = Vector3.new(4, 0.4, 8),
CFrame = origin * CFrame.new(0, -0.9, 0),
CanCollide = false,
Parent = model,
})
ballast.Transparency = 1
ballast.CustomPhysicalProperties = PhysicalProperties.new(8, 0.3, 0.5, 1, 1)
local ballastWeld = Instance.new("WeldConstraint")
ballastWeld.Part0 = body
ballastWeld.Part1 = ballast
ballastWeld.Parent = body
-- Cabin (visual only) -------------------------------------------------------
local cabin = makePart({
Name = "Cabin",
Size = Vector3.new(5, 1.6, 5),
CFrame = origin * CFrame.new(0, 1.5, 0.5),
Color = Color3.new(car.Color[1] * 0.7, car.Color[2] * 0.7, car.Color[3] * 0.7),
Material = Enum.Material.Glass,
CanCollide = false,
Parent = model,
})
local cabinWeld = Instance.new("WeldConstraint")
cabinWeld.Part0 = body
cabinWeld.Part1 = cabin
cabinWeld.Parent = body
cabin.CFrame = body.CFrame * CFrame.new(0, 1.8, 0.5)
-- VehicleSeat: Roblox's built-in, reliable driver ------------------------
-- Seat: input device only (Throttle/Steer). It drives nothing directly. -----
local seat = Instance.new("VehicleSeat")
seat.Name = "DriveSeat"
seat.Name = Chassis.SEAT_NAME
seat.Size = Vector3.new(4, 0.5, 3)
seat.MaxSpeed = maxSpeed
seat.Torque = torque
seat.TurnSpeed = turnSpeed
seat.MaxSpeed = 0 -- neutralise built-in drive; constraints do the work
seat.Torque = 0
seat.TurnSpeed = 0
seat.HeadsUpDisplay = false
seat.CanCollide = false
seat.Color = Color3.new(0.1, 0.1, 0.1)
seat.CFrame = origin * CFrame.new(0, 1.2, -0.5)
seat.Parent = model
local seatWeld = Instance.new("WeldConstraint")
seatWeld.Part0 = body
seatWeld.Part1 = seat
seatWeld.Parent = body
seat.CFrame = body.CFrame * CFrame.new(0, 1.6, -1)
-- Four suspension corners ---------------------------------------------------
for _, xSign in ipairs({ -1, 1 }) do
for _, zSign in ipairs({ -1, 1 }) do
buildCorner(model, body, tuning, xSign, zSign)
end
end
model.PrimaryPart = body
return model
@@ -123,22 +290,26 @@ function CarManager.spawn(player: Player, carId: string?)
CarManager.despawn(player)
local maxSpeed, torque, turnSpeed = computeTuning(car)
local model = buildCarModel(car, maxSpeed, torque, turnSpeed)
local tuning = Chassis.tuningFor(car)
local model = buildCarModel(car, tuning)
model:PivotTo(spawnCFrame())
model.Parent = carsFolder
model:SetAttribute("OwnerUserId", player.UserId)
model:SetAttribute("CarId", id)
-- Sit the player down. Give the assembly network ownership to the driver so
-- physics are smooth on their client (standard Roblox vehicle practice).
-- Give the driver network ownership of EVERY assembly in the car. With a
-- constraint chassis the body, hubs, knuckles, and wheels are SEPARATE
-- assemblies (constraints don't merge them like welds do) — missing one
-- causes stuttery mixed-authority physics.
for _, desc in ipairs(model:GetDescendants()) do
if desc:IsA("BasePart") and not desc.Anchored then
desc:SetNetworkOwner(player)
end
end
local character = player.Character
if character then
local seat = model:FindFirstChild("DriveSeat") :: VehicleSeat
local body = model.PrimaryPart
if body then
body:SetNetworkOwner(player)
end
local seat = model:FindFirstChild(Chassis.SEAT_NAME) :: VehicleSeat
if seat then
task.defer(function()
seat:Sit(character:FindFirstChildOfClass("Humanoid"))
+70
View File
@@ -0,0 +1,70 @@
--!strict
--[[
Chassis.luau (SHARED)
Single source of truth for the constraint chassis: geometry constants,
part/constraint naming, and per-car tuning derived from the 0-100 stats.
Used by BOTH sides:
- Server (CarManager.buildCarModel) builds the physical chassis from it.
- Client (ChassisController) drives the motors/servos from it.
Sign conventions (car forward = -Z, Roblox standard):
- DRIVE_SIGN: wheel motor AngularVelocity sign for forward throttle.
- STEER_SIGN: steering servo TargetAngle sign for steering right.
If a future geometry change makes the car drive/steer backwards, flip the
sign here — nowhere else.
]]
local Cars = require(script.Parent.Cars)
type Car = Cars.Car
local Chassis = {}
-- Naming conventions (server creates, client finds) ---------------------------
Chassis.SEAT_NAME = "DriveSeat"
Chassis.WHEEL_MOTOR_NAME = "WheelMotor" -- HingeConstraint (Motor) spinning a wheel
Chassis.STEER_SERVO_NAME = "SteerServo" -- HingeConstraint (Servo) yawing a front knuckle
-- Sign conventions ------------------------------------------------------------
Chassis.DRIVE_SIGN = -1 -- forward (-Z) needs negative angular velocity about +X
Chassis.STEER_SIGN = -1 -- steer right (Steer = 1) needs negative yaw about +Y
-- Geometry (studs) ------------------------------------------------------------
Chassis.BODY_SIZE = Vector3.new(6, 1.4, 12)
Chassis.WHEEL_RADIUS = 1.4
Chassis.WHEEL_WIDTH = 1.1
Chassis.TRACK_HALF = 3.35 -- lateral distance body centre -> wheel centre
Chassis.WHEELBASE_HALF = 4.2 -- longitudinal distance body centre -> axle
Chassis.SUSPENSION_DROP = 1.6 -- rest distance from body centre down to wheel centre
Chassis.SUSPENSION_TRAVEL = 0.9 -- total prismatic travel allowed
-- Per-car tuning derived from stats -------------------------------------------
export type Tuning = {
MaxSpeed: number, -- studs/sec target top speed
MaxWheelAngularVelocity: number, -- rad/sec at top speed
MotorMaxTorque: number, -- accel authority
BrakeTorque: number, -- resist-to-zero torque when throttle released
MaxSteerAngle: number, -- degrees
SteerSpeed: number, -- servo angular speed, rad/sec
SpringStiffness: number,
SpringDamping: number,
WheelFriction: number,
}
function Chassis.tuningFor(car: Car): Tuning
local maxSpeed = 70 + car.Stats.Speed * 1.5
return {
MaxSpeed = maxSpeed,
MaxWheelAngularVelocity = maxSpeed / Chassis.WHEEL_RADIUS,
MotorMaxTorque = 12000 + car.Stats.Acceleration * 500,
BrakeTorque = 30000,
MaxSteerAngle = 22 + car.Stats.Handling * 0.16, -- ~28-38 deg
SteerSpeed = 6,
SpringStiffness = 9000,
SpringDamping = 420,
WheelFriction = 1.2 + car.Stats.Handling * 0.008, -- ~1.2-2.0
}
end
return Chassis