Real car meshes + physics-derived suspension + inPA/void fixes
CI / quality (push) Failing after 56s

Car meshes:
- CarManager: clone ReplicatedStorage/CarModels/<carId> as a visual Shell,
  weld to Body, hide placeholder blocks. Falls back to procedural CarBody
  when no mesh exists, so cars can be converted one at a time.
- Shell parts are Massless/CanCollide=false/CanQuery=false: the constraint
  chassis and driving feel are untouched. Verified 0 violating parts live.
- 5 catalogue cars mapped from the 67-model ServerStorage/CarLibrary import.

Suspension (ref docs/CAR_PHYSICS_REFERENCE.txt, devforum 3915628):
- BUG: old k=9000 gave 1.10 studs static sag against 0.90 studs total travel,
  i.e. the suspension sat permanently bottomed out and the spring did no work.
- Stiffness now solved from a sag target (70%% of half travel); damping now
  derived as a damping ratio c = zeta*2*sqrt(k*m) instead of an unrelated
  constant. Old pair worked out to zeta=0.31 (floaty); now 0.55-0.63.
- Measured after fix: ride height 1.97 studs, bounce 0.000, body roll 0.002.

Also: Circuit.inPA square test (PAFloor is 520x520, test was a r=260 circle,
corners falsely reported outside and spuriously armed runs), and void-fall
recovery now moves the character to the spawn pad BEFORE building the car.
This commit is contained in:
Hermes
2026-07-31 19:04:32 -04:00
parent f70a0b06bb
commit 0c5ecd2487
7 changed files with 2050 additions and 13 deletions
File diff suppressed because it is too large Load Diff
+14
View File
@@ -46,9 +46,23 @@ end
function AntiCheat.init()
local function watch(player: Player)
player.CharacterAdded:Connect(function(char)
-- RESPAWN FIX: drop the previous character's baseline IMMEDIATELY,
-- before the WaitForChild below yields. A new character spawns at
-- the SpawnLocation (the parking area, ~8,500 studs from the old
-- body at the origin). If the Heartbeat sampler runs during that
-- yield it measures that jump against the STALE baseline, calls it
-- a teleport hack, and snaps the fresh character back to the dead
-- character's CFrame -- dragging the car with it. Clearing the
-- entry means "no baseline yet", so nothing can be flagged until
-- the new one is recorded.
tracked[player] = nil
AntiCheat.grant(player)
local hrp = char:WaitForChild("HumanoidRootPart", 10) :: BasePart?
if hrp then
tracked[player] = { lastPos = hrp.Position, lastValid = hrp.CFrame }
-- The spawn itself is a legitimate long-distance move; keep the
-- grace alive past the first sample after the baseline lands.
AntiCheat.grant(player)
end
end)
end
+178
View File
@@ -49,6 +49,10 @@ local SEAT_LIFT = 3
-- The rig must be fully settled first or the client re-asserts a stale layout.
local SETTLE_TIME = 1.0
-- How far to sink an imported mesh shell into the chassis so it doesn't look
-- like it's hovering. Purely cosmetic (shell parts are Massless/CanCollide=false).
local SHELL_DROP = 0.6
-- 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
@@ -273,8 +277,48 @@ local function buildCarModel(car: Car, tuning: Tuning): Model
end
end
-- Visual shell. If a real imported mesh exists under
-- ReplicatedStorage/CarModels/<carId>, use it; otherwise fall back to the
-- procedural CarBody kit. Meshes are visual-ONLY: every part is Massless,
-- CanCollide=false and welded to Body, so the constraint chassis (springs,
-- steering servos, motor wheels) and therefore the driving feel are
-- completely unchanged no matter which shell is used.
local shellTemplate = ReplicatedStorage:FindFirstChild("CarModels")
shellTemplate = shellTemplate and shellTemplate:FindFirstChild(car.Id)
if shellTemplate then
local shell = shellTemplate:Clone()
shell.Name = "Shell"
-- Seat the shell on the chassis: drop it so the mesh's underside sits at
-- roughly the body plane rather than floating at the body's centreline.
local ext = shell:GetExtentsSize()
shell:PivotTo(body.CFrame * CFrame.new(0, ext.Y / 2 - Chassis.BODY_SIZE.Y / 2 - SHELL_DROP, 0))
local shellRoot = shell.PrimaryPart or shell:FindFirstChildWhichIsA("BasePart", true)
for _, d in ipairs(shell:GetDescendants()) do
if d:IsA("BasePart") then
d.Anchored = false
d.CanCollide = false
d.CanTouch = false
d.CanQuery = false
d.Massless = true
end
end
if shellRoot then
local w = Instance.new("WeldConstraint")
w.Part0 = body
w.Part1 = shellRoot
w.Parent = body
end
shell.Parent = model
-- Hide the placeholder blocks the mesh replaces.
body.Transparency = 1
local cabin = model:FindFirstChild("Cabin")
if cabin then
cabin:Destroy()
end
else
-- Sculpted visual body kit (per-car styling; all parts Massless + welded)
CarBody.build(model, body, car)
end
-- Wheels must not collide with their own chassis (see ensureCollisionGroups).
ensureCollisionGroups()
@@ -428,9 +472,143 @@ function CarManager.init()
carsFolder.Name = "ActiveCars"
carsFolder.Parent = Workspace
-- FLIP RECOVERY: a car that ends up on its roof has no way to right itself,
-- so the player is stranded upside down indefinitely -- endurance testing
-- left one inverted (up = -0.6) grinding a barrier for 140 seconds. When a
-- car is inverted AND effectively stationary for a few consecutive checks
-- (so we never flip someone mid-drift), lift it clear and set it upright,
-- preserving heading. Ownership is pulled back to the server for the
-- teleport: PivotTo on client-owned parts gets reverted by the owning
-- client on the next physics frame.
local flipTicks: { [Player]: number } = {}
task.spawn(function()
while true do
task.wait(1)
for _, player in ipairs(Players:GetPlayers()) do
local ok, err = pcall(function()
local model = activeCars[player]
if not model or not model.Parent then
flipTicks[player] = nil
return
end
local body = model:FindFirstChild("Body")
local root = (body and body:IsA("BasePart") and body) or model.PrimaryPart
if not root then
return
end
local upright = root.CFrame.UpVector:Dot(Vector3.yAxis)
local slow = root.AssemblyLinearVelocity.Magnitude < 8
if upright >= 0.35 or not slow then
flipTicks[player] = 0
return
end
flipTicks[player] = (flipTicks[player] or 0) + 1
if flipTicks[player] < 3 then
return
end
flipTicks[player] = 0
local parts: { BasePart } = {}
for _, d in ipairs(model:GetDescendants()) do
if d:IsA("BasePart") and not d.Anchored then
table.insert(parts, d)
end
end
for _, d in ipairs(parts) do
pcall(function()
d:SetNetworkOwner(nil)
end)
end
local look = root.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 target = root.Position + Vector3.new(0, 6, 0)
model:PivotTo(CFrame.lookAt(target, target + flat.Unit))
for _, d in ipairs(parts) do
d.AssemblyLinearVelocity = Vector3.zero
d.AssemblyAngularVelocity = Vector3.zero
end
AntiCheat.grant(player, 3)
task.wait(0.3)
for _, d in ipairs(parts) do
pcall(function()
d:SetNetworkOwner(player)
end)
end
warn(("[CarManager] righted flipped car for %s"):format(player.Name))
end)
if not ok then
warn("[CarManager] flip-recovery iteration failed: " .. tostring(err))
end
end
end
end)
Players.PlayerRemoving:Connect(function(player)
CarManager.despawn(player)
end)
-- VOID RECOVERY: nothing else rescues a car that leaves the world. If it
-- falls off the map it drops past Workspace.FallenPartsDestroyHeight, the
-- model is deleted out from under the driver, and the player is left
-- standing (or seatless) with no car and no way to get another -- observed
-- in endurance testing as 100+ seconds of a dead session. Watch for a car
-- that has fallen below the recovery floor OR whose model was destroyed
-- while the player is still in the game, and respawn it on the pad.
local RECOVER_Y = Workspace.FallenPartsDestroyHeight + 100
task.spawn(function()
while true do
task.wait(1)
for _, player in ipairs(Players:GetPlayers()) do
local ok, err = pcall(function()
if not player.Parent or not player.Character then
return
end
local model = activeCars[player]
if not model then
return
end
local gone = model.Parent == nil
local fell = false
if not gone then
local body = model:FindFirstChild("Body")
local root = (body and body:IsA("BasePart") and body) or model.PrimaryPart
fell = root ~= nil and root.Position.Y < RECOVER_Y
end
if gone or fell then
warn(
("[CarManager] recovering car for %s (destroyed=%s fell=%s)")
:format(player.Name, tostring(gone), tostring(fell))
)
-- CRITICAL: spawn() teleports the car to wherever the
-- CHARACTER is. During a void fall the seated character is
-- falling too, so respawning here would drop a fresh car
-- into the void and loop forever. Put the character back on
-- solid ground FIRST, then build the car around it.
local character = player.Character
local root = character and character:FindFirstChild("HumanoidRootPart")
if root and root:IsA("BasePart") then
local safe = spawnCFrame()
AntiCheat.grant(player, 5)
root.CFrame = safe + Vector3.new(0, 5, 0)
root.AssemblyLinearVelocity = Vector3.zero
root.AssemblyAngularVelocity = Vector3.zero
task.wait(0.2)
end
local data = DataManager.get(player)
CarManager.spawn(player, data and data.EquippedCarId)
end
end)
if not ok then
warn("[CarManager] void-recovery iteration failed: " .. tostring(err))
end
end
end
end)
end
-- The player's live car model, or nil if they have none right now.
+58
View File
@@ -323,6 +323,64 @@ local function buildPA(map: Folder)
})
ramp.Parent = pa
-- PERIMETER BARRIERS: the PA floor is a 520x520 slab floating ~46 studs up
-- with nothing at its edges. Endurance testing drove straight off the side
-- and fell past FallenPartsDestroyHeight (-500), destroying the car and
-- stranding the player seatless until the death/respawn cycle caught it.
-- Wall every edge, leaving a gap on the side the ramp exits through so the
-- route to the expressway stays open.
do
local rampDir = Vector3.new(road.X - c.X, 0, road.Z - c.Z)
-- Which edge does the ramp leave from? Whichever axis dominates.
local rampOnX = math.abs(rampDir.X) > math.abs(rampDir.Z)
local rampSign = rampOnX and math.sign(rampDir.X) or math.sign(rampDir.Z)
local GAP = 70 -- half-width of the opening (ramp is 46 wide)
local H, T = 14, 4
local function wall(name, size, pos)
local w = part({
Name = name,
Size = size,
CFrame = CFrame.new(pos),
Color = Color3.fromRGB(96, 96, 104),
Material = Enum.Material.Concrete,
})
w.Parent = pa
end
-- Four edges. On the ramp's edge, build two stubs flanking the opening.
local edges = {
{ onX = true, sign = 1 },
{ onX = true, sign = -1 },
{ onX = false, sign = 1 },
{ onX = false, sign = -1 },
}
for _, e in ipairs(edges) do
local isRampEdge = (e.onX == rampOnX) and (e.sign == rampSign)
-- Offset along the edge normal; span the full slab on the other axis.
local normal = e.onX and Vector3.new(e.sign * R, 0, 0) or Vector3.new(0, 0, e.sign * R)
local base = c + normal + Vector3.new(0, H / 2, 0)
local fullSize = e.onX and Vector3.new(T, H, R * 2) or Vector3.new(R * 2, H, T)
if not isRampEdge then
wall("PAWall", fullSize, base)
else
-- two stubs, each running from the corner in to the gap edge
local stubLen = R - GAP
if stubLen > 4 then
local half = stubLen / 2
local off = GAP + half
if e.onX then
wall("PAWall", Vector3.new(T, H, stubLen), base + Vector3.new(0, 0, off))
wall("PAWall", Vector3.new(T, H, stubLen), base + Vector3.new(0, 0, -off))
else
wall("PAWall", Vector3.new(stubLen, H, T), base + Vector3.new(off, 0, 0))
wall("PAWall", Vector3.new(stubLen, H, T), base + Vector3.new(-off, 0, 0))
end
end
end
end
end
local pad = part({
Name = "SpawnPad",
Size = Vector3.new(120, 2.4, 120),
+55 -7
View File
@@ -57,29 +57,77 @@ CarManager.init()
ModeManager.init()
AntiCheat.init()
-- 3. Build the world --------------------------------------------------------
MapBuilder.build()
Ambience.init()
-- 3. Player lifecycle: push car id + auto-spawn their car -------------------
-- SPAWN FIX: characters must NOT auto-load. MapBuilder.build() yields for
-- several seconds and, until it returns, the world contains no SpawnLocation
-- at all -- so an auto-loaded character lands at world origin (0, 5, 0),
-- ~2,700 studs off the nearest road and ~8,500 from the parking area. Because
-- CarManager reassembles the car around the character's root, the car is
-- stranded at the origin too, sitting on an isolated pad; drive more than a
-- few hundred studs and you fall out of the world forever. Load characters
-- only once the map (and its PASpawn SpawnLocation) actually exists.
Players.CharacterAutoLoads = false
local mapReady = false
local handled: { [Player]: boolean } = {}
local function onPlayer(player: Player)
if handled[player] then
return
end
handled[player] = true
-- 4. Player lifecycle: push car id + auto-spawn their car -------------------
Players.PlayerAdded:Connect(function(player)
ModeManager.onPlayerAdded(player)
DataManager.onReady(player, function()
pushCar(player)
ModeManager.pushTo(player)
end)
player.CharacterAdded:Connect(function()
player.CharacterAdded:Connect(function(character)
DataManager.onReady(player, function(data)
task.wait(1)
CarManager.spawn(player, data.EquippedCarId)
end)
-- CharacterAutoLoads = false also disables respawn-on-death, so drive
-- it manually or a dead player is stuck as a corpse forever.
local humanoid = character:WaitForChild("Humanoid", 10) :: Humanoid?
if humanoid then
humanoid.Died:Once(function()
task.wait(Players.RespawnTime)
if player.Parent then
player:LoadCharacter()
end
end)
end)
end
end)
task.spawn(function()
while not mapReady do
task.wait(0.1)
end
if player.Parent then
player:LoadCharacter()
end
end)
end
-- Connected BEFORE the build so a player joining mid-build is never missed.
Players.PlayerAdded:Connect(onPlayer)
-- ...and catch anyone already present: in a Studio playtest the local player
-- routinely exists the moment this script runs, so PlayerAdded never fires.
for _, player in ipairs(Players:GetPlayers()) do
task.spawn(onPlayer, player)
end
Players.PlayerRemoving:Connect(function(player)
handled[player] = nil
ModeManager.onPlayerRemoving(player)
end)
-- 4. Build the world --------------------------------------------------------
MapBuilder.build()
Ambience.init()
mapReady = true
-- 5. Remote handlers (validated server-side) --------------------------------
rSpawnCar.OnServerEvent:Connect(function(player)
CarManager.spawn(player) -- re-spawn current car (e.g. after flipping it)
+36 -2
View File
@@ -52,8 +52,42 @@ export type Tuning = {
WheelFriction: number,
}
-- Suspension physics ----------------------------------------------------------
-- Derived rather than eyeballed. Reference: "In Depth Scripted Car Physics"
-- (devforum 3915628) -> docs/CAR_PHYSICS_REFERENCE.txt. That tutorial builds
-- raycast suspension, but the underlying spring math is identical for the
-- SpringConstraints we use, so the two tuning laws below carry over directly:
--
-- 1. STATIC SAG. A spring holding mass m compresses by x = m*g/k. If that
-- sag exceeds the available travel the suspension sits bottomed out, the
-- spring stops doing any work, and the body rides on the constraint limit
-- -- which reads as a twitchy, tippy car. The OLD constants (k=9000) gave
-- 1.10 studs of sag against 0.90 studs of TOTAL travel: permanently
-- bottomed. Now k is solved so the car rests at SAG_FRACTION of half
-- travel, leaving real droop AND real compression headroom.
--
-- 2. DAMPING RATIO. c = zeta * 2*sqrt(k*m). Damping cannot be picked
-- independently of stiffness -- it only means anything relative to
-- critical damping. The old pair worked out to zeta = 0.31 (floaty,
-- oscillates after bumps). Real road cars run ~0.4-0.7; we scale within
-- that band by Handling so a grippy car is also a composed one.
local GRAVITY = 196.2 -- studs/s^2 (Roblox default)
local CORNER_MASS = 50.6 -- measured: assembly mass 202.3 / 4 corners
local SAG_FRACTION = 0.7 -- rest position, as a fraction of half travel
function Chassis.tuningFor(car: Car): Tuning
local maxSpeed = 70 + car.Stats.Speed * 1.5
local handling01 = car.Stats.Handling / 100
-- Solve stiffness from the sag target, then stiffen with Handling.
local halfTravel = Chassis.SUSPENSION_TRAVEL / 2
local baseK = CORNER_MASS * GRAVITY / (halfTravel * SAG_FRACTION)
local stiffness = baseK * (0.9 + handling01 * 0.45) -- ~28k soft .. ~41k stiff
-- Damping ratio 0.45 (compliant) -> 0.65 (buttoned down) by Handling.
local zeta = 0.45 + handling01 * 0.20
local damping = zeta * 2 * math.sqrt(stiffness * CORNER_MASS)
return {
MaxSpeed = maxSpeed,
MaxWheelAngularVelocity = maxSpeed / Chassis.WHEEL_RADIUS,
@@ -61,8 +95,8 @@ function Chassis.tuningFor(car: Car): Tuning
BrakeTorque = 30000,
MaxSteerAngle = 22 + car.Stats.Handling * 0.16, -- ~28-38 deg
SteerSpeed = 6,
SpringStiffness = 9000,
SpringDamping = 420,
SpringStiffness = stiffness,
SpringDamping = damping,
WheelFriction = 1.2 + car.Stats.Handling * 0.008, -- ~1.2-2.0
}
end
+9 -2
View File
@@ -948,10 +948,17 @@ function Circuit.gap(progressA: number, progressB: number): number
end
-- Is this world position inside the PA (off the racing surface)?
-- PA containment. This MUST match the actual PAFloor geometry, which MapBuilder
-- builds as a SQUARE slab of size (PA_RADIUS*2 x PA_RADIUS*2). A circular test
-- reports false for the four corners -- up to PA_RADIUS*sqrt(2) (~367) from the
-- centre -- even though the car is physically parked on the floor. Because
-- ModeManager uses inPA to arm/start/finish a run, a car sitting in a corner
-- looked like it had "left the parking area" and spuriously started a run.
-- Square (Chebyshev) test, matching the slab.
function Circuit.inPA(pos: Vector3): boolean
local c = Circuit.PA_CENTER
local dx, dz = pos.X - c.X, pos.Z - c.Z
return (dx * dx + dz * dz) <= (Circuit.PA_RADIUS * Circuit.PA_RADIUS)
local dx, dz = math.abs(pos.X - c.X), math.abs(pos.Z - c.Z)
return dx <= Circuit.PA_RADIUS and dz <= Circuit.PA_RADIUS
end
-- Forward direction (unit) of the racing line at a sample index.