--!strict --[[ CarManager.luau (SERVER) Spawns the player's equipped car as a CONSTRAINT CHASSIS vehicle. 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") local Workspace = game:GetService("Workspace") 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) local CarBody = require(script.Parent.CarBody) local AntiCheat = require(script.Parent.AntiCheat) local PhysicsService = game:GetService("PhysicsService") type Car = Cars.Car type Tuning = Chassis.Tuning local CarManager = {} local activeCars: { [Player]: Model } = {} local carsFolder: Folder -- Collision groups ------------------------------------------------------------ -- The wheels deliberately overlap the chassis: with BODY_SIZE.X = 6 the body -- edge sits at 3.0, while a wheel at TRACK_HALF 3.35 with WHEEL_WIDTH 1.1 has -- its inner edge at 2.80 -- a 0.2 stud lateral interpenetration. Vertically the -- wheel top reaches -0.2 against a body bottom of -0.7. Left in the same -- collision group the wheels grind against the body and the car will not roll -- (motors spin at ~1 rad/s against 20,000 torque). Putting wheels in their own -- group that ignores the chassis is the fix; the geometry is intentional. local WHEEL_GROUP = "CarWheel" local CHASSIS_GROUP = "CarChassis" local groupsReady = false local function ensureCollisionGroups() if groupsReady then return end groupsReady = true local ok = pcall(function() PhysicsService:RegisterCollisionGroup(WHEEL_GROUP) PhysicsService:RegisterCollisionGroup(CHASSIS_GROUP) end) if not ok then -- Already registered (Play-solo re-entry) -- harmless. end PhysicsService:CollisionGroupSetCollidable(WHEEL_GROUP, CHASSIS_GROUP, false) PhysicsService:CollisionGroupSetCollidable(CHASSIS_GROUP, CHASSIS_GROUP, true) PhysicsService:CollisionGroupSetCollidable(WHEEL_GROUP, WHEEL_GROUP, false) end -- 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 -- 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) -- Rigid hub mount. A PrismaticConstraint+SpringConstraint suspension was used -- here, but the hub (0.6 cube) is ~3 orders of magnitude lighter than the -- ballasted body, and that mass ratio across a stiff spring makes the solver -- diverge: the hub/knuckle/wheel assemblies get flung to ~1e7 studs, fall -- past FallenPartsDestroyHeight and are deleted -- leaving a car with a body -- and no wheels that cannot move. Welding the hub makes it part of the body -- assembly, which is rock stable. Ride is rigid; the car actually drives. local hubWeld = Instance.new("WeldConstraint") hubWeld.Name = "SuspWeld_" .. cornerName hubWeld.Part0 = body hubWeld.Part1 = hub hubWeld.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 local origin = CFrame.new() -- built at origin; PivotTo() places it later -- 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 -- Visual bodywork (nose, glass, spoilers, lights, underglow, rims) is added -- by CarBody.build() after assembly. All CarBody parts are Massless, so -- they never alter the chassis physics. -- Seat: input device only (Throttle/Steer). It drives nothing directly. ----- local seat = Instance.new("VehicleSeat") seat.Name = Chassis.SEAT_NAME seat.Size = Vector3.new(4, 0.5, 3) 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 pan just above the body slab (top = H/2): low enough to look tucked -- in the cabin, high enough that the avatar's legs never interpenetrate -- the slab (which causes physics launches). seat.CFrame = origin * CFrame.new(0, Chassis.BODY_SIZE.Y / 2 + 0.3, 0.2) seat.Parent = model local seatWeld = Instance.new("WeldConstraint") seatWeld.Part0 = body seatWeld.Part1 = seat seatWeld.Parent = body -- 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 -- Sculpted visual body kit (per-car styling; all parts Massless + welded) CarBody.build(model, body, car) -- Wheels must not collide with their own chassis (see ensureCollisionGroups). ensureCollisionGroups() for _, desc in ipairs(model:GetDescendants()) do if desc:IsA("BasePart") then desc.CollisionGroup = if desc.Name:match("^Wheel_") then WHEEL_GROUP else CHASSIS_GROUP end end model.PrimaryPart = body return model end -- Find a reasonable spawn CFrame (a SpawnPad in the map, else origin) -------- -- CharacterAdded can fire before MapBuilder.build() has finished, in which case -- a naive lookup silently falls back to the origin and dumps the car ~8,600 -- studs off-circuit. Wait briefly for the pad rather than guessing. local function spawnCFrame(): CFrame local deadline = os.clock() + 10 repeat local map = Workspace:FindFirstChild("Map") local pad = map and map:FindFirstChild("SpawnPad", true) if pad and pad:IsA("BasePart") then return pad.CFrame * CFrame.new(0, 5, 0) end task.wait(0.1) until os.clock() > deadline warn("[CarManager] SpawnPad never appeared; falling back to origin.") return CFrame.new(0, 8, 0) end --- Despawn the player's current car if any. function CarManager.despawn(player: Player) local existing = activeCars[player] if existing then existing:Destroy() activeCars[player] = nil end end --- Spawn (or replace) the player's equipped car and seat them in it. function CarManager.spawn(player: Player, carId: string?) local data = DataManager.get(player) if not data then return end local id = carId or data.EquippedCarId local car = Cars.Get(id) if not car then return end -- Restricted car (RX-7) can only be driven by allowlisted UserIds. if not GameConfig.canDrive(player.UserId, car.Id, car.Restricted) then return end CarManager.despawn(player) local tuning = Chassis.tuningFor(car) local model = buildCarModel(car, tuning) local target = spawnCFrame() model.Parent = carsFolder -- Pivot AFTER parenting: pivoting an unparented model then reparenting lets -- the physics solver re-resolve from the model's build origin, which drops -- the car back at (0,0,0) instead of the spawn pad. model:PivotTo(target) model:SetAttribute("OwnerUserId", player.UserId) model:SetAttribute("CarId", id) -- 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(Chassis.SEAT_NAME) :: VehicleSeat if seat then -- The character is CLIENT-owned, so a server-side PivotTo of it does -- NOT stick: it holds for one frame and the client snaps it back to -- where it was. Sit() then drags the seat's assembly across the map -- to reach the character -- and because a constraint chassis keeps -- the body, knuckles and wheels as SEPARATE assemblies, only the -- body follows. The knuckles/wheels stay behind at the pad and the -- car is torn apart (measured ~15,700 studs), later falling out of -- the world. -- -- Fix: don't fight the client. Record every part's offset from the -- body while the model is still intact, let Sit() and the character -- snap-back fully settle, then REASSEMBLE the car around wherever -- the body actually ended up. local primary = model.PrimaryPart local offsets: { [BasePart]: CFrame } = {} local movable: { BasePart } = {} if primary then local inverse = primary.CFrame:Inverse() for _, desc in ipairs(model:GetDescendants()) do if desc:IsA("BasePart") and not desc.Anchored then offsets[desc] = inverse * desc.CFrame table.insert(movable, desc) end end end AntiCheat.grant(player) character:PivotTo(target * CFrame.new(0, 3, 0)) task.defer(function() seat:Sit(character:FindFirstChildOfClass("Humanoid")) if not primary then return end -- Hold server authority while the rig is in pieces, otherwise -- the client re-asserts the torn layout over our repair. for _, part in ipairs(movable) do pcall(function() part:SetNetworkOwner(nil) end) end task.wait(1.2) -- Sit() + client snap-back settle here if not primary:IsDescendantOf(Workspace) then return end -- Rebuild every assembly around the body, forced level so the -- car never lands on its roof. local position = primary.Position local look = primary.CFrame.LookVector local flat = Vector3.new(look.X, 0, look.Z) if flat.Magnitude < 0.1 then flat = Vector3.new(0, 0, -1) end local base = CFrame.lookAt(position, position + flat.Unit) for _, part in ipairs(movable) do if part:IsDescendantOf(Workspace) then part.CFrame = base * offsets[part] part.AssemblyLinearVelocity = Vector3.zero part.AssemblyAngularVelocity = Vector3.zero end end task.wait(0.3) -- let the solver accept the repaired rig AntiCheat.grant(player) for _, part in ipairs(movable) do if part:IsDescendantOf(Workspace) then pcall(function() part:SetNetworkOwner(player) end) end end end) end end activeCars[player] = model end function CarManager.init() carsFolder = Instance.new("Folder") carsFolder.Name = "ActiveCars" carsFolder.Parent = Workspace Players.PlayerRemoving:Connect(function(player) CarManager.despawn(player) end) end -- The player's live car model, or nil if they have none right now. function CarManager.getCar(player: Player): Model? local model = activeCars[player] if model and model.Parent then return model end return nil end -- The physics root of the player's car (the chassis Body). ModeManager samples -- this to measure real, server-side position — never client-reported. function CarManager.getRoot(player: Player): BasePart? local model = CarManager.getCar(player) if not model then return nil end local body = model:FindFirstChild("Body") if body and body:IsA("BasePart") then return body end return model.PrimaryPart end return CarManager