fix: car now drives - collision groups, rigid hubs, pad spawn, anticheat grace

Four independent bugs kept the car immobile at the world origin:

1. Wheels collided with their own chassis. Wheel inner edge sits at 2.80
   vs body edge 3.00, and wheel top -0.20 vs body bottom -0.70. The
   overlap is intentional styling, but in one collision group the wheels
   ground against the body: motors spun ~1 rad/s against 20,000 torque.
   Wheels now use a CarWheel group that ignores CarChassis.

2. Suspension exploded on spawn. PrismaticConstraint+SpringConstraint
   between a 0.6-stud hub and the ballasted body was unstable enough to
   fling knuckles ~10,000,000 studs out, past FallenPartsDestroyHeight,
   deleting the wheels. Replaced with a rigid welded hub mount.

3. Car spawned at world origin, not the pad. MapBuilder made a SpawnPad
   part but no SpawnLocation, so characters spawned at (0,0,0) and Sit()
   dragged the car to them. Added a real SpawnLocation and the model is
   now pivoted after parenting, with the character teleported first.

4. AntiCheat snapped that legitimate spawn teleport back. An ~8,500-stud
   server move reads as a teleport hack. Added AntiCheat.grant() so
   server-initiated moves are exempt for a short grace window.

Verified in Studio: 881 studs in 6s, 496 studs/s top speed, 152 deg
steering, braking 33 -> 0 studs/s, chassis stays upright.
This commit is contained in:
2026-07-31 10:04:47 -04:00
parent 766fce5088
commit d45f35736a
5 changed files with 139 additions and 31 deletions
+15 -1
View File
@@ -25,6 +25,19 @@ type Track = { lastPos: Vector3, lastValid: CFrame }
local tracked: { [Player]: Track } = {}
local SAMPLE_INTERVAL = 0.5
-- Server-initiated teleports (car spawn onto the pad, respawn, mode resets)
-- legitimately move a player thousands of studs in one frame, which trips the
-- speed check and snaps them straight back -- dragging their car with them.
-- Callers announce those moves via AntiCheat.grant() so the next sample is
-- accepted as the new baseline instead of treated as a hack.
local graceUntil: { [Player]: number } = {}
local GRACE_SECONDS = 2
--- Exempt a player from the speed check briefly after a legitimate teleport.
function AntiCheat.grant(player: Player, seconds: number?)
graceUntil[player] = os.clock() + (seconds or GRACE_SECONDS)
end
local function hrpOf(player: Player): BasePart?
local char = player.Character
return char and char:FindFirstChild("HumanoidRootPart") :: BasePart?
@@ -66,7 +79,8 @@ function AntiCheat.init()
local horizontal = Vector3.new(pos.X - track.lastPos.X, 0, pos.Z - track.lastPos.Z).Magnitude
local hSpeed = horizontal / interval
if hSpeed > GameConfig.MaxPlausibleSpeed then
local inGrace = (graceUntil[player] or 0) > os.clock()
if hSpeed > GameConfig.MaxPlausibleSpeed and not inGrace then
-- Snap back: likely a speed/teleport hack.
hrp.CFrame = track.lastValid
hrp.AssemblyLinearVelocity = Vector3.zero
+72 -23
View File
@@ -30,6 +30,8 @@ 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
@@ -39,6 +41,35 @@ 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
@@ -102,24 +133,18 @@ local function buildCorner(model: Model, body: Part, tuning: Tuning, xSign: numb
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
-- 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
@@ -243,19 +268,33 @@ local function buildCarModel(car: Car, tuning: Tuning): Model
-- 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 map = Workspace:FindFirstChild("Map")
if map then
local pad = map:FindFirstChild("SpawnPad", true)
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
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
@@ -288,8 +327,12 @@ function CarManager.spawn(player: Player, carId: string?)
local tuning = Chassis.tuningFor(car)
local model = buildCarModel(car, tuning)
model:PivotTo(spawnCFrame())
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)
@@ -307,6 +350,12 @@ function CarManager.spawn(player: Player, carId: string?)
if character then
local seat = model:FindFirstChild(Chassis.SEAT_NAME) :: VehicleSeat
if seat then
-- Teleport the character to the car before seating. Without this the
-- character is still at its own spawn point and Sit() drags the car
-- back across the map to reach them. Grant AntiCheat grace first --
-- an ~8,500-stud jump reads as a teleport hack and gets snapped back.
AntiCheat.grant(player)
character:PivotTo(target * CFrame.new(0, 3, 0))
task.defer(function()
seat:Sit(character:FindFirstChildOfClass("Humanoid"))
end)
+15
View File
@@ -333,6 +333,21 @@ local function buildPA(map: Folder)
})
pad.Parent = pa
-- Roblox drops the character at (0,0,0) unless a real SpawnLocation exists,
-- and CarManager spawns the car onto the character -- so without this the
-- player starts ~8,600 studs off-circuit regardless of where SpawnPad is.
-- Neutral + non-collidable so it never becomes an obstacle on the pad.
local spawnLoc = Instance.new("SpawnLocation")
spawnLoc.Name = "PASpawn"
spawnLoc.Size = Vector3.new(24, 1, 24)
spawnLoc.CFrame = CFrame.new(c + Vector3.new(0, 2, 0))
spawnLoc.Transparency = 1
spawnLoc.CanCollide = false
spawnLoc.Anchored = true
spawnLoc.Neutral = true
spawnLoc.Duration = 0
spawnLoc.Parent = pa
makeSign("大黒PA DAIKOKU PA", CFrame.new(c + Vector3.new(0, 26, -R + 10)), pa)
end