M1/M2: chase camera + expressway map v2
CI / quality (push) Successful in 52s

CameraController (client):
- Scriptable chase cam only while in the DriveSeat; restores default on exit
- Frame-rate independent damped follow with look-ahead aim point
- Flat horizon (ignores car pitch/roll) so bumps don't shake the view
- Speed-based FOV kick (quadratic ramp) for sense of pace
- Cleans up on Died/despawn

MapBuilder v2:
- Highway extended 2400 -> 3200 studs with distinct feature zones
- Gantry arches (seg 2), enclosed neon-lit tunnel (seg 3), jump ramp +
  landing pair on the right lane (seg 4, left lane stays flat)
- Procedural neon skyline towers flanking both sides (deterministic seed,
  same city every server)
- End kill-wall so you can't sail off the far edge
This commit is contained in:
2026-07-22 01:09:44 -04:00
parent b3a1044df6
commit e94bb8fcda
4 changed files with 418 additions and 67 deletions
+7 -2
View File
@@ -21,14 +21,19 @@ to owner + son. Cars persist between sessions. CI green on a self-hosted runner.
(driver owns network physics). **Needs a Studio playtest to tune feel.**
- [ ] Import the **real RX-7 mesh**; weld the shell onto the chassis `Body`.
See `docs/MESH_SWAP.md` for the exact steps (needs Studio for asset upload).
- [ ] Tune acceleration curve, grip, and the chase camera (after first playtest).
- [ ] Tune acceleration curve and grip (after first playtest).
- [x] **Chase camera**: scriptable follow cam while seated — damped chase with
look-ahead, flat horizon (ignores car pitch/roll), speed-based FOV kick.
`src/client/CameraController.luau`. Tune constants after playtest.
- [ ] Sound: engine loop, tire screech, boost.
**Exit test:** the RX-7 is genuinely fun to just drive around. Ship nothing else
until this is true.
## M2 — More cars + polish (still no racing)
- [ ] Import a few more real car meshes.
- [ ] Better map: more expressway sections, ramps, tunnels, scenery.
- [x] Better map v2: 3200-stud expressway with gantry arches, a neon-lit
tunnel, jump ramps (right lane; left stays flat), end wall, and a
procedural neon skyline flanking the route.
- [ ] Day/night, weather mood.
## M3 — Racing (the first "game" layer)
+136
View File
@@ -0,0 +1,136 @@
--!strict
--[[
CameraController.luau (CLIENT)
Chase camera for driving. Replaces the default Roblox camera ONLY while the
local player sits in a DriveSeat; hands the camera straight back when they
get out.
Feel decisions (tunables at the top):
- The camera chases a point behind/above the car with critically-damped
smoothing (frame-rate independent lerp), so it lags into corners and
catches up on straights — classic arcade racer feel.
- It looks ahead of the car (not at it), which reads speed better.
- FOV widens with speed for a cheap but very effective sense of pace.
- Yaw follows the car's facing, but the position smoothing means drifts
and slides read visually (car rotates inside the frame first).
]]
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Shared = ReplicatedStorage.Shared
local Chassis = require(Shared.Chassis)
local player = Players.LocalPlayer
local CameraController = {}
-- Tunables --------------------------------------------------------------------
local CAM_DISTANCE = 24 -- studs behind the car
local CAM_HEIGHT = 9 -- studs above the car
local LOOK_AHEAD = 14 -- aim this far in front of the car
local POSITION_RESPONSIVENESS = 6 -- higher = tighter follow (1/sec)
local BASE_FOV = 70
local MAX_FOV_KICK = 18 -- extra FOV at (chassis) top speed
local FOV_RESPONSIVENESS = 3
-- State -------------------------------------------------------------------------
local renderConn: RBXScriptConnection? = nil
local seatedConn: RBXScriptConnection? = nil
local savedCameraType: Enum.CameraType? = nil
local savedFov: number? = nil
-- Frame-rate independent smoothing factor: fraction of remaining distance
-- covered this frame. alpha = 1 - e^(-k*dt).
local function smoothAlpha(k: number, dt: number): number
return 1 - math.exp(-k * dt)
end
local function startChase(seat: VehicleSeat)
local model = seat:FindFirstAncestorOfClass("Model")
local body = model and model.PrimaryPart
if not body then
return
end
local bodyPart = body :: BasePart
local camera = Workspace.CurrentCamera
savedCameraType = camera.CameraType
savedFov = camera.FieldOfView
camera.CameraType = Enum.CameraType.Scriptable
-- Start from wherever the car is so there's no first-frame snap.
local camPos = bodyPart.Position + bodyPart.CFrame.LookVector * -CAM_DISTANCE + Vector3.new(0, CAM_HEIGHT, 0)
renderConn = RunService.RenderStepped:Connect(function(dt)
if not bodyPart.Parent then
return -- car despawned; SeatPart signal will clean us up
end
local look = bodyPart.CFrame.LookVector
local flatLook = Vector3.new(look.X, 0, look.Z)
if flatLook.Magnitude < 0.01 then
flatLook = Vector3.new(0, 0, -1)
else
flatLook = flatLook.Unit
end
-- Where the camera WANTS to be: behind and above, following flat yaw
-- (ignoring car pitch/roll so bumps don't shake the horizon).
local desired = bodyPart.Position - flatLook * CAM_DISTANCE + Vector3.new(0, CAM_HEIGHT, 0)
camPos = camPos:Lerp(desired, smoothAlpha(POSITION_RESPONSIVENESS, dt))
local lookTarget = bodyPart.Position + flatLook * LOOK_AHEAD
camera.CFrame = CFrame.lookAt(camPos, lookTarget)
-- Speed-based FOV kick.
local speed = bodyPart.AssemblyLinearVelocity.Magnitude
-- Normalise against a nominal fast top speed; Chassis gives per-car
-- values but a fixed reference keeps slow cars feeling slow.
local speedFrac = math.clamp(speed / 190, 0, 1)
local targetFov = BASE_FOV + MAX_FOV_KICK * speedFrac * speedFrac
camera.FieldOfView = camera.FieldOfView + (targetFov - camera.FieldOfView) * smoothAlpha(FOV_RESPONSIVENESS, dt)
end)
end
local function stopChase()
if renderConn then
renderConn:Disconnect()
renderConn = nil
end
local camera = Workspace.CurrentCamera
if camera then
camera.CameraType = savedCameraType or Enum.CameraType.Custom
camera.FieldOfView = savedFov or BASE_FOV
end
savedCameraType = nil
savedFov = nil
end
function CameraController.init()
local function onCharacter(character: Model)
local humanoid = character:WaitForChild("Humanoid") :: Humanoid
if seatedConn then
seatedConn:Disconnect()
end
seatedConn = humanoid:GetPropertyChangedSignal("SeatPart"):Connect(function()
stopChase()
local seatPart = humanoid.SeatPart
if seatPart and seatPart:IsA("VehicleSeat") and seatPart.Name == Chassis.SEAT_NAME then
startChase(seatPart)
end
end)
-- Character died/respawned while driving: make sure the camera is back.
humanoid.Died:Connect(stopChase)
end
if player.Character then
onCharacter(player.Character)
end
player.CharacterAdded:Connect(onCharacter)
end
return CameraController
+2
View File
@@ -16,8 +16,10 @@ local Shared = ReplicatedStorage.Shared
local Remotes = require(Shared.Remotes)
local Cars = require(Shared.Cars)
local ChassisController = require(script.ChassisController)
local CameraController = require(script.CameraController)
ChassisController.init()
CameraController.init()
local player = Players.LocalPlayer
local playerGui = player:WaitForChild("PlayerGui")
+273 -65
View File
@@ -1,13 +1,22 @@
--!strict
--[[
MapBuilder.luau (SERVER)
Builds the MVP "Tokyo expressway" vertical slice entirely from code so the
game is playable with ZERO manual Studio building. This is a straight,
banked-guardrail highway segment with a start pad, a finish line, neon-ish
side pylons, and invisible kill walls so you can't drive off the edge.
Builds the "Tokyo expressway" map entirely from code — ZERO manual Studio
building. v2 (M2 groundwork): the straight highway now runs through a
sequence of FEATURES so driving it is actually interesting:
Later you replace this with a proper modeled map; the rest of the game
(spawns, races) reads named parts so it keeps working.
seg 0-1 open start (spawn pad)
seg 2 overhead gantry arches
seg 3 TUNNEL (enclosed, neon-striped ceiling)
seg 4 jump ramps (side-by-side launch + landing, optional — road
continues flat around them)
seg 5-6 open run to the finish line
seg 7 finish + runoff
Neon skyline blocks flank the whole route outside the guardrails for the
night-city mood. Everything is still named parts (SpawnPad, FinishLine,
KillWall_*) so gameplay code keeps working when this is replaced by a
modeled map later.
]]
local Workspace = game:GetService("Workspace")
@@ -16,8 +25,12 @@ local MapBuilder = {}
local ROAD_WIDTH = 60
local SEGMENT_LENGTH = 400
local NUM_SEGMENTS = 6 -- total length = 2400 studs of highway
local WALL_HEIGHT = 20
local NUM_SEGMENTS = 8 -- total length = 3200 studs of highway
local WALL_HEIGHT = 26
local NEON_PINK = Color3.fromRGB(255, 40, 120)
local NEON_BLUE = Color3.fromRGB(40, 160, 255)
local NEON_TEAL = Color3.fromRGB(40, 230, 200)
local function part(props: { [string]: any }): Part
local p = Instance.new("Part")
@@ -30,6 +43,242 @@ local function part(props: { [string]: any }): Part
return p
end
-- Deterministic pseudo-random for scenery (same city every server) ------------
local function det(seed: number): number
local x = math.sin(seed * 12.9898) * 43758.5453
return x - math.floor(x)
end
-- Road deck ------------------------------------------------------------------
local function buildRoad(parent: Folder)
for i = 0, NUM_SEGMENTS - 1 do
local z = -(i * SEGMENT_LENGTH) - SEGMENT_LENGTH / 2
local road = part({
Name = "Road_" .. i,
Size = Vector3.new(ROAD_WIDTH, 2, SEGMENT_LENGTH),
Position = Vector3.new(0, 0, z),
Color = Color3.fromRGB(28, 28, 34),
Material = Enum.Material.Asphalt,
})
road.Parent = parent
local line = part({
Name = "Lane_" .. i,
Size = Vector3.new(1, 2.1, SEGMENT_LENGTH),
Position = Vector3.new(0, 0.1, z),
Color = Color3.fromRGB(230, 200, 60),
Material = Enum.Material.Neon,
})
line.Parent = parent
end
end
-- Guardrails, kill walls, pylons ----------------------------------------------
local function buildBarriers(map: Folder, totalLength: number)
for _, side in ipairs({ -1, 1 }) do
local x = side * (ROAD_WIDTH / 2)
local rail = part({
Name = "Guardrail_" .. (side == -1 and "L" or "R"),
Size = Vector3.new(1.5, 4, totalLength),
Position = Vector3.new(x, 2, -totalLength / 2),
Color = Color3.fromRGB(180, 180, 190),
Material = Enum.Material.Metal,
})
rail.Parent = map
local wall = part({
Name = "KillWall_" .. (side == -1 and "L" or "R"),
Size = Vector3.new(2, WALL_HEIGHT, totalLength),
Position = Vector3.new(x + side * 2, WALL_HEIGHT / 2, -totalLength / 2),
Transparency = 1,
CanCollide = true,
})
wall.Parent = map
for i = 0, math.floor(totalLength / 200) do
local pylon = part({
Name = "Pylon",
Size = Vector3.new(1, 14, 1),
Position = Vector3.new(x + side * 3, 7, -(i * 200)),
Color = side == -1 and NEON_PINK or NEON_BLUE,
Material = Enum.Material.Neon,
})
pylon.Parent = map
end
end
end
-- Overhead gantry arches (segment 2) -------------------------------------------
local function buildGantries(map: Folder)
local folder = Instance.new("Folder")
folder.Name = "Gantries"
folder.Parent = map
local segStart = 2 * SEGMENT_LENGTH
for g = 0, 3 do
local z = -(segStart + 60 + g * 90)
for _, side in ipairs({ -1, 1 }) do
local post = part({
Name = "GantryPost",
Size = Vector3.new(2, 24, 2),
Position = Vector3.new(side * (ROAD_WIDTH / 2 - 2), 12, z),
Color = Color3.fromRGB(60, 60, 70),
Material = Enum.Material.DiamondPlate,
})
post.Parent = folder
end
local beam = part({
Name = "GantryBeam",
Size = Vector3.new(ROAD_WIDTH, 2.5, 2.5),
Position = Vector3.new(0, 24, z),
Color = g % 2 == 0 and NEON_TEAL or NEON_PINK,
Material = Enum.Material.Neon,
})
beam.Parent = folder
end
end
-- Tunnel (segment 3): walls + ceiling with neon strips ---------------------------
local function buildTunnel(map: Folder)
local folder = Instance.new("Folder")
folder.Name = "Tunnel"
folder.Parent = map
local zCenter = -(3 * SEGMENT_LENGTH) - SEGMENT_LENGTH / 2
local tunnelHeight = 22
for _, side in ipairs({ -1, 1 }) do
local wall = part({
Name = "TunnelWall_" .. (side == -1 and "L" or "R"),
Size = Vector3.new(3, tunnelHeight, SEGMENT_LENGTH),
Position = Vector3.new(side * (ROAD_WIDTH / 2 + 1.5), tunnelHeight / 2, zCenter),
Color = Color3.fromRGB(38, 38, 46),
Material = Enum.Material.Concrete,
})
wall.Parent = folder
end
local ceiling = part({
Name = "TunnelCeiling",
Size = Vector3.new(ROAD_WIDTH + 6, 2, SEGMENT_LENGTH),
Position = Vector3.new(0, tunnelHeight + 1, zCenter),
Color = Color3.fromRGB(30, 30, 38),
Material = Enum.Material.Concrete,
})
ceiling.Parent = folder
-- Neon ceiling strips light the tunnel (one every 50 studs, alternating).
for i = 0, math.floor(SEGMENT_LENGTH / 50) do
local z = -(3 * SEGMENT_LENGTH) - i * 50
local strip = part({
Name = "TunnelLight",
Size = Vector3.new(ROAD_WIDTH * 0.5, 0.6, 2),
Position = Vector3.new(0, tunnelHeight - 0.5, z),
Color = i % 2 == 0 and NEON_BLUE or NEON_TEAL,
Material = Enum.Material.Neon,
})
strip.Parent = folder
end
end
-- Jump ramps (segment 4): launch + landing pair on the right half ---------------
local function buildRamps(map: Folder)
local folder = Instance.new("Folder")
folder.Name = "Ramps"
folder.Parent = map
local zStart = 4 * SEGMENT_LENGTH + 100
local rampX = ROAD_WIDTH / 4 -- right half of the road; left half stays flat
-- Launch ramp: a wedge sloping up in the direction of travel (-Z).
local launch = Instance.new("WedgePart")
launch.Anchored = true
launch.Name = "RampLaunch"
launch.Size = Vector3.new(ROAD_WIDTH / 2 - 4, 10, 48)
-- WedgePart slopes from its -Z (tall) to +Z (thin edge); car travels -Z,
-- so face the thin edge toward the approaching car (+Z toward car = yaw 180).
launch.CFrame = CFrame.new(rampX, 5, -zStart) * CFrame.Angles(0, math.rad(180), 0)
launch.Color = Color3.fromRGB(50, 50, 62)
launch.Material = Enum.Material.Metal
launch.TopSurface = Enum.SurfaceType.Smooth
launch.Parent = folder
local edge = part({
Name = "RampEdgeGlow",
Size = Vector3.new(ROAD_WIDTH / 2 - 4, 0.8, 1),
Position = Vector3.new(rampX, 10.4, -zStart - 24),
Color = NEON_PINK,
Material = Enum.Material.Neon,
})
edge.Parent = folder
-- Landing ramp: mirrored wedge further down so clean jumps land smooth.
local landing = Instance.new("WedgePart")
landing.Anchored = true
landing.Name = "RampLanding"
landing.Size = Vector3.new(ROAD_WIDTH / 2 - 4, 10, 60)
landing.CFrame = CFrame.new(rampX, 5, -(zStart + 150))
landing.Color = Color3.fromRGB(50, 50, 62)
landing.Material = Enum.Material.Metal
landing.TopSurface = Enum.SurfaceType.Smooth
landing.Parent = folder
end
-- Neon skyline: procedural building blocks flanking the route -------------------
local function buildSkyline(map: Folder, totalLength: number)
local folder = Instance.new("Folder")
folder.Name = "Skyline"
folder.Parent = map
local accents = { NEON_PINK, NEON_BLUE, NEON_TEAL, Color3.fromRGB(200, 120, 255) }
for _, side in ipairs({ -1, 1 }) do
local n = 0
local z = -80
while z > -totalLength do
n += 1
local seed = side * 1000 + n
local width = 40 + det(seed) * 60
local depth = 40 + det(seed + 1) * 60
local height = 40 + det(seed + 2) * 160
local gap = 30 + det(seed + 3) * 70
local x = side * (ROAD_WIDTH / 2 + 50 + det(seed + 4) * 90 + width / 2)
local tower = part({
Name = "Tower",
Size = Vector3.new(width, height, depth),
Position = Vector3.new(x, height / 2 - 10, z - depth / 2),
Color = Color3.fromRGB(16, 16, 24),
Material = Enum.Material.Slate,
})
tower.Parent = folder
-- Lit window band + rooftop accent (2 parts per tower, cheap).
local accent = accents[(n % #accents) + 1]
local band = part({
Name = "TowerBand",
Size = Vector3.new(width + 0.4, 2 + det(seed + 5) * 4, depth + 0.4),
Position = Vector3.new(x, height * (0.4 + det(seed + 6) * 0.4), z - depth / 2),
Color = accent,
Material = Enum.Material.Neon,
Transparency = 0.2,
})
band.Parent = folder
local roof = part({
Name = "TowerRoof",
Size = Vector3.new(width * 0.3, 1.5, depth * 0.3),
Position = Vector3.new(x, height - 9, z - depth / 2),
Color = accent,
Material = Enum.Material.Neon,
})
roof.Parent = folder
z = z - depth - gap
end
end
end
function MapBuilder.build(): Folder
local existing = Workspace:FindFirstChild("Map")
if existing then
@@ -45,63 +294,12 @@ function MapBuilder.build(): Folder
local totalLength = SEGMENT_LENGTH * NUM_SEGMENTS
-- Road deck (segmented so you can later bank/curve individual pieces) ------
for i = 0, NUM_SEGMENTS - 1 do
local z = -(i * SEGMENT_LENGTH) - SEGMENT_LENGTH / 2
local road = part({
Name = "Road_" .. i,
Size = Vector3.new(ROAD_WIDTH, 2, SEGMENT_LENGTH),
Position = Vector3.new(0, 0, z),
Color = Color3.fromRGB(28, 28, 34),
Material = Enum.Material.Asphalt,
})
road.Parent = roadFolder
-- Center lane line
local line = part({
Name = "Lane_" .. i,
Size = Vector3.new(1, 2.1, SEGMENT_LENGTH),
Position = Vector3.new(0, 0.1, z),
Color = Color3.fromRGB(230, 200, 60),
Material = Enum.Material.Neon,
})
line.Parent = roadFolder
end
-- Guardrails + invisible kill walls on both sides -------------------------
for _, side in ipairs({ -1, 1 }) do
local x = side * (ROAD_WIDTH / 2)
local rail = part({
Name = "Guardrail_" .. (side == -1 and "L" or "R"),
Size = Vector3.new(1.5, 4, totalLength),
Position = Vector3.new(x, 2, -totalLength / 2),
Color = Color3.fromRGB(180, 180, 190),
Material = Enum.Material.Metal,
})
rail.Parent = map
-- Invisible wall: solid, tall, non-rendered — the "can't leave" barrier.
local wall = part({
Name = "KillWall_" .. (side == -1 and "L" or "R"),
Size = Vector3.new(2, WALL_HEIGHT, totalLength),
Position = Vector3.new(x + side * 2, WALL_HEIGHT / 2, -totalLength / 2),
Transparency = 1,
CanCollide = true,
})
wall.Parent = map
-- Neon pylons for that cyberpunk Tokyo glow (cheap: one every 200 studs)
for i = 0, NUM_SEGMENTS * 2 do
local pylon = part({
Name = "Pylon",
Size = Vector3.new(1, 14, 1),
Position = Vector3.new(x + side * 3, 7, -(i * 200)),
Color = side == -1 and Color3.fromRGB(255, 40, 120) or Color3.fromRGB(40, 160, 255),
Material = Enum.Material.Neon,
})
pylon.Parent = map
end
end
buildRoad(roadFolder)
buildBarriers(map, totalLength)
buildGantries(map)
buildTunnel(map)
buildRamps(map)
buildSkyline(map, totalLength)
-- Spawn pad (where cars appear) at the start ------------------------------
local pad = part({
@@ -125,6 +323,16 @@ function MapBuilder.build(): Folder
})
finish.Parent = map
-- End wall so you can't sail off the far edge ------------------------------
local endWall = part({
Name = "KillWall_End",
Size = Vector3.new(ROAD_WIDTH + 8, WALL_HEIGHT, 2),
Position = Vector3.new(0, WALL_HEIGHT / 2, -totalLength - 1),
Transparency = 1,
CanCollide = true,
})
endWall.Parent = map
-- A floor far below to catch anything that clips through (safety net) ------
local floor = part({
Name = "SafetyFloor",