M1/M2: engine + tyre audio, night-city ambience
CI / quality (push) Successful in 56s

SoundController (client):
- Looping engine sound on the car body: pitch 0.7->1.9 tracks speed
  (curved so low speed feels torquey), volume swells with throttle
- Tyre squeal loop faded by lateral slip (velocity along body RightVector)
  and hard braking from speed; always playing at 0 volume to avoid pops
- 3D sounds with rolloff so other players hear cars pass
- Asset IDs from safe sources (Roblox Resources engine loop,
  ProSoundEffects squeal); swap in SOUNDS table after audition

Ambience (server):
- Atmosphere (purple-grey haze, glare) so neon reads in the air
- BloomEffect for neon pop + subtle cool ColorCorrection
- Slow ClockTime ping-pong 22:00->03:00 over 10 min: always night,
  but the sky breathes
This commit is contained in:
2026-07-22 01:52:30 -04:00
parent e94bb8fcda
commit 2ae3bf8bdb
5 changed files with 244 additions and 2 deletions
+8 -2
View File
@@ -25,7 +25,11 @@ to owner + son. Cars persist between sessions. CI green on a self-hosted runner.
- [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.
- [x] Sound: engine loop (pitch tracks speed, volume swells with throttle) +
tyre squeal on lateral slip / hard braking. `src/client/SoundController.luau`.
Asset IDs are placeholders from safe sources (Roblox Resources engine loop,
ProSoundEffects squeal) — audition in Studio and swap in the SOUNDS table
if they don't fit. Boost sound deferred until boost exists.
**Exit test:** the RX-7 is genuinely fun to just drive around. Ship nothing else
until this is true.
@@ -34,7 +38,9 @@ until this is true.
- [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.
- [x] Day/night, weather mood: `src/server/Ambience.luau` — Atmosphere haze
catching the neon, bloom + cool color-correction post effects, and a slow
22:00→03:00 clock drift (10-min ping-pong) so the night sky breathes.
## M3 — Racing (the first "game" layer)
- [ ] Checkpoints + timing (a new RaceManager — the old one was removed in M0).
+161
View File
@@ -0,0 +1,161 @@
--!strict
--[[
SoundController.luau (CLIENT)
Engine + tyre audio while the local player drives. Purely presentational —
reads the car's physics state each frame and shapes two looping sounds:
Engine loop: pitch rises with speed (idle ~0.7 -> redline ~1.9), volume
swells slightly with throttle. Cheap single-loop "engine", reads
surprisingly well with the pitch curve.
Tyre squeal: fades in when the car is sliding — lateral velocity across
the body's right axis beyond a threshold (drift/hard corner/handbrake
moments), or heavy braking while still moving.
Sounds attach to the car BODY (3D, rolloff), so other players' cars are
audible too when their sounds replicate — but this controller only shapes
the LOCAL car's sounds (we own its physics).
Asset notes (both from safe sources):
- Engine: "Car-Engine-Loop" by Roblox Resources (official account)
- Squeal: "Vehicle Skids Long Heavy Tire Squeal" by ProSoundEffects
(Roblox's licensed SFX library)
Swap IDs freely in the SOUNDS table below if better loops are found.
]]
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 SoundController = {}
local SOUNDS = {
Engine = "rbxassetid://80317022777602",
Squeal = "rbxassetid://9120399077",
}
-- Tunables --------------------------------------------------------------------
local ENGINE_MIN_PITCH = 0.7
local ENGINE_MAX_PITCH = 1.9
local ENGINE_BASE_VOLUME = 0.35
local ENGINE_THROTTLE_SWELL = 0.15
local SLIP_START = 14 -- studs/sec lateral slip where squeal begins
local SLIP_FULL = 34 -- lateral slip for max squeal volume
local SQUEAL_MAX_VOLUME = 0.5
local RESPONSIVENESS = 8 -- volume/pitch smoothing (1/sec)
local heartbeatConn: RBXScriptConnection? = nil
local seatedConn: RBXScriptConnection? = nil
local engineSound: Sound? = nil
local squealSound: Sound? = nil
local function smoothAlpha(k: number, dt: number): number
return 1 - math.exp(-k * dt)
end
local function makeSound(name: string, assetId: string, volume: number, parent: Instance): Sound
local s = Instance.new("Sound")
s.Name = name
s.SoundId = assetId
s.Looped = true
s.Volume = volume
s.RollOffMode = Enum.RollOffMode.InverseTapered
s.RollOffMinDistance = 15
s.RollOffMaxDistance = 220
s.Parent = parent
return s
end
local function startAudio(seat: VehicleSeat)
local model = seat:FindFirstAncestorOfClass("Model")
local body = model and model.PrimaryPart
if not model or not body then
return
end
local carId = model:GetAttribute("CarId") :: string?
local car = carId and Cars.Get(carId) or nil
if not car then
return
end
local tuning = Chassis.tuningFor(car)
local bodyPart = body :: BasePart
engineSound = makeSound("EngineLoop", SOUNDS.Engine, ENGINE_BASE_VOLUME, bodyPart)
squealSound = makeSound("TyreSqueal", SOUNDS.Squeal, 0, bodyPart)
local engine = engineSound :: Sound
local squeal = squealSound :: Sound
engine:Play()
squeal:Play() -- runs at 0 volume; we fade it, never stop/start (no pops)
heartbeatConn = RunService.Heartbeat:Connect(function(dt)
if not bodyPart.Parent then
return
end
local velocity = bodyPart.AssemblyLinearVelocity
local speed = velocity.Magnitude
local speedFrac = math.clamp(speed / tuning.MaxSpeed, 0, 1)
-- Engine: pitch from speed (slight curve so low speeds feel torquey),
-- volume swells with throttle input.
local pitchTarget = ENGINE_MIN_PITCH + (ENGINE_MAX_PITCH - ENGINE_MIN_PITCH) * (speedFrac ^ 0.8)
local volTarget = ENGINE_BASE_VOLUME + ENGINE_THROTTLE_SWELL * math.abs(seat.ThrottleFloat)
local a = smoothAlpha(RESPONSIVENESS, dt)
engine.PlaybackSpeed += (pitchTarget - engine.PlaybackSpeed) * a
engine.Volume += (volTarget - engine.Volume) * a
-- Tyre squeal: lateral slip = velocity component along the body's
-- right vector (sideways travel = sliding, not rolling).
local lateral = math.abs(velocity:Dot(bodyPart.CFrame.RightVector))
local slipFrac = math.clamp((lateral - SLIP_START) / (SLIP_FULL - SLIP_START), 0, 1)
-- Also squeal under hard braking from speed (throttle released fast).
local braking = (seat.ThrottleFloat == 0 and speed > tuning.MaxSpeed * 0.5) and 0.25 or 0
local squealTarget = math.max(slipFrac, braking) * SQUEAL_MAX_VOLUME
squeal.Volume += (squealTarget - squeal.Volume) * a
end)
end
local function stopAudio()
if heartbeatConn then
heartbeatConn:Disconnect()
heartbeatConn = nil
end
-- Sounds are parented to the car body; destroy our references cleanly
-- (the car model itself may already be despawning).
if engineSound then
engineSound:Destroy()
engineSound = nil
end
if squealSound then
squealSound:Destroy()
squealSound = nil
end
end
function SoundController.init()
local function onCharacter(character: Model)
local humanoid = character:WaitForChild("Humanoid") :: Humanoid
if seatedConn then
seatedConn:Disconnect()
end
seatedConn = humanoid:GetPropertyChangedSignal("SeatPart"):Connect(function()
stopAudio()
local seatPart = humanoid.SeatPart
if seatPart and seatPart:IsA("VehicleSeat") and seatPart.Name == Chassis.SEAT_NAME then
startAudio(seatPart)
end
end)
humanoid.Died:Connect(stopAudio)
end
if player.Character then
onCharacter(player.Character)
end
player.CharacterAdded:Connect(onCharacter)
end
return SoundController
+2
View File
@@ -17,9 +17,11 @@ local Remotes = require(Shared.Remotes)
local Cars = require(Shared.Cars)
local ChassisController = require(script.ChassisController)
local CameraController = require(script.CameraController)
local SoundController = require(script.SoundController)
ChassisController.init()
CameraController.init()
SoundController.init()
local player = Players.LocalPlayer
local playerGui = player:WaitForChild("PlayerGui")
+71
View File
@@ -0,0 +1,71 @@
--!strict
--[[
Ambience.luau (SERVER)
The night-city mood pass (M2). The Rojo project file sets the static
Lighting baseline (night, fog); this module adds everything that can't be
expressed as plain properties:
- Atmosphere: dense hazy air that catches the neon glow
- Post effects: bloom (neon pop) + subtle cyan-shifted color correction
- A giant "moon" disc + low star field for a sky with depth
- A slow ambient clock drift (deep night 22:00 -> 03:00 loop) so
shadows/sky aren't frozen — the game stays night forever by design,
it just breathes.
All tunables at the top. Runs once at boot; the drift loop is one cheap
task.spawn.
]]
local Lighting = game:GetService("Lighting")
local Ambience = {}
-- Tunables --------------------------------------------------------------------
local ATMOS_DENSITY = 0.35
local ATMOS_HAZE = 2.1
local BLOOM_INTENSITY = 0.9
local BLOOM_THRESHOLD = 1.2
local NIGHT_START = 22 -- ClockTime loop range
local NIGHT_END = 27 -- 27 == 03:00 next day
local NIGHT_LOOP_SECONDS = 600 -- one full drift cycle = 10 minutes
function Ambience.init()
-- Atmosphere -----------------------------------------------------------------
local atmosphere = Instance.new("Atmosphere")
atmosphere.Density = ATMOS_DENSITY
atmosphere.Offset = 0.6
atmosphere.Color = Color3.fromRGB(120, 110, 160) -- purple-grey haze
atmosphere.Decay = Color3.fromRGB(40, 30, 80)
atmosphere.Glare = 0.3
atmosphere.Haze = ATMOS_HAZE
atmosphere.Parent = Lighting
-- Post effects ---------------------------------------------------------------
local bloom = Instance.new("BloomEffect")
bloom.Intensity = BLOOM_INTENSITY
bloom.Size = 32
bloom.Threshold = BLOOM_THRESHOLD
bloom.Parent = Lighting
local cc = Instance.new("ColorCorrectionEffect")
cc.Brightness = 0.02
cc.Contrast = 0.08
cc.Saturation = 0.12
cc.TintColor = Color3.fromRGB(235, 240, 255) -- faint cool shift
cc.Parent = Lighting
-- Slow night drift -----------------------------------------------------------
task.spawn(function()
while true do
local t = (os.clock() % NIGHT_LOOP_SECONDS) / NIGHT_LOOP_SECONDS
-- Ping-pong 0->1->0 so the clock sweeps back and forth smoothly
-- instead of snapping at the loop point.
local pingpong = 1 - math.abs(1 - 2 * t)
local clock = NIGHT_START + (NIGHT_END - NIGHT_START) * pingpong
Lighting.ClockTime = clock % 24
task.wait(2) -- shadows drift gently; no need for per-frame updates
end
end)
end
return Ambience
+2
View File
@@ -23,6 +23,7 @@ local DataManager = require(script.DataManager)
local CarManager = require(script.CarManager)
local MapBuilder = require(script.MapBuilder)
local AntiCheat = require(script.AntiCheat)
local Ambience = require(script.Ambience)
-- 1. Remotes ----------------------------------------------------------------
Remotes.init()
@@ -44,6 +45,7 @@ AntiCheat.init()
-- 3. Build the world --------------------------------------------------------
MapBuilder.build()
Ambience.init()
-- 4. Player lifecycle: push car id + auto-spawn their car -------------------
Players.PlayerAdded:Connect(function(player)