Scope cut: drive-only MVP (remove racing + economy)
CI / quality (push) Successful in 1m1s

Per user direction, the MVP is now simply driving cars on the expressway.
- Removed RaceManager and Economy modules entirely
- Slimmed Remotes to DataUpdated / SpawnCar / EquipCar
- ProfileTemplate now just remembers the last driven car (v2)
- Cars: dropped price/rep/upgrade fields; kept identity + driving stats
- CarManager: stat-based tuning only; RX-7 allowlist via GameConfig.canDrive
- DataManager: assign default car on join; downgrade RX-7 if de-allowlisted
- Client: replaced garage/economy UI with a simple car picker + respawn
- Docs: GDD/ROADMAP/README rewritten for drive-only; added docs/MESH_SWAP.md
- Gates: stylua clean, selene 0/0, rojo builds
This commit is contained in:
2026-07-22 00:11:11 -04:00
parent 5fc03d611d
commit 07a67204ca
14 changed files with 347 additions and 781 deletions
+74 -178
View File
@@ -1,10 +1,12 @@
--!strict
--[[
init.client.luau (CLIENT ENTRY POINT)
Builds the HUD (cash / rep / car), a Garage+Dealership panel, and a Daily
button — all in code so there's nothing to build in Studio. The client is
purely presentational: it shows server-provided data and sends requests.
It never decides money or ownership.
init.client.luua (CLIENT ENTRY POINT)
MVP scope: JUST DRIVING. Minimal UI:
- A small HUD showing the current car name.
- A "CARS" button opening a picker to switch cars (RX-7 shown only if you
can drive it — the server enforces this regardless).
- A "RESPAWN CAR" button to re-spawn your car if you flip or fall off.
The client is presentational; the server owns everything.
]]
local Players = game:GetService("Players")
@@ -17,17 +19,11 @@ local Cars = require(Shared.Cars)
local player = Players.LocalPlayer
local playerGui = player:WaitForChild("PlayerGui")
-- Remotes -------------------------------------------------------------------
local rDataUpdated = Remotes.get("DataUpdated") :: RemoteEvent
local rSpawnCar = Remotes.get("SpawnCar") :: RemoteEvent
local rEquipCar = Remotes.get("EquipCar") :: RemoteFunction
local rBuyCar = Remotes.get("BuyCar") :: RemoteFunction
local rUpgradeCar = Remotes.get("UpgradeCar") :: RemoteFunction
local rClaimDaily = Remotes.get("ClaimDaily") :: RemoteFunction
local rStartRace = Remotes.get("StartRace") :: RemoteFunction
-- Local cache of the latest server snapshot ---------------------------------
local latest: any = nil
local currentCarId = ""
-- UI helpers ----------------------------------------------------------------
local NEON_PINK = Color3.fromRGB(255, 40, 120)
@@ -55,10 +51,10 @@ local screen = make("ScreenGui", {
ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
}, playerGui)
-- ===== HUD (top-left): cash / rep / car ====================================
-- HUD: current car ----------------------------------------------------------
local hud = make("Frame", {
Name = "HUD",
Size = UDim2.fromOffset(260, 96),
Size = UDim2.fromOffset(260, 52),
Position = UDim2.fromOffset(16, 16),
BackgroundColor3 = PANEL_BG,
BackgroundTransparency = 0.15,
@@ -67,44 +63,22 @@ local hud = make("Frame", {
corner(hud, 10)
make("UIStroke", { Color = NEON_PINK, Thickness = 1.5, Transparency = 0.3 }, hud)
local cashLabel = make("TextLabel", {
Size = UDim2.new(1, -20, 0, 28),
Position = UDim2.fromOffset(12, 8),
local carLabel = make("TextLabel", {
Size = UDim2.new(1, -20, 1, 0),
Position = UDim2.fromOffset(12, 0),
BackgroundTransparency = 1,
Font = Enum.Font.GothamBold,
TextColor3 = Color3.fromRGB(120, 255, 170),
TextXAlignment = Enum.TextXAlignment.Left,
TextSize = 20,
Text = "$ --",
}, hud)
local repLabel = make("TextLabel", {
Size = UDim2.new(1, -20, 0, 22),
Position = UDim2.fromOffset(12, 38),
BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium,
TextColor3 = NEON_BLUE,
TextColor3 = Color3.fromRGB(230, 230, 240),
TextXAlignment = Enum.TextXAlignment.Left,
TextSize = 16,
Text = "REP --",
}, hud)
local carLabel = make("TextLabel", {
Size = UDim2.new(1, -20, 0, 20),
Position = UDim2.fromOffset(12, 64),
BackgroundTransparency = 1,
Font = Enum.Font.Gotham,
TextColor3 = Color3.fromRGB(220, 220, 230),
TextXAlignment = Enum.TextXAlignment.Left,
TextSize = 14,
Text = "Car: --",
}, hud)
-- ===== Bottom action bar ===================================================
-- Bottom action bar ---------------------------------------------------------
local bar = make("Frame", {
Name = "ActionBar",
Size = UDim2.fromOffset(430, 52),
Position = UDim2.new(0.5, -215, 1, -70),
Size = UDim2.fromOffset(230, 52),
Position = UDim2.new(0.5, -115, 1, -70),
BackgroundTransparency = 1,
}, screen)
make("UIListLayout", {
@@ -116,7 +90,7 @@ make("UIListLayout", {
local function button(text: string, color: Color3, order: number): TextButton
local b = make("TextButton", {
Size = UDim2.fromOffset(100, 44),
Size = UDim2.fromOffset(105, 44),
BackgroundColor3 = PANEL_BG,
BackgroundTransparency = 0.1,
Font = Enum.Font.GothamBold,
@@ -132,12 +106,10 @@ local function button(text: string, color: Color3, order: number): TextButton
return b
end
local garageBtn = button("GARAGE", NEON_BLUE, 1)
local spawnBtn = button("SPAWN CAR", Color3.fromRGB(120, 255, 170), 2)
local raceBtn = button("RACE", NEON_PINK, 3)
local dailyBtn = button("DAILY", Color3.fromRGB(255, 210, 80), 4)
local carsBtn = button("CARS", NEON_BLUE, 1)
local respawnBtn = button("RESPAWN", Color3.fromRGB(120, 255, 170), 2)
-- ===== Toast (transient messages) ==========================================
-- Toast ---------------------------------------------------------------------
local toast = make("TextLabel", {
Name = "Toast",
Size = UDim2.fromOffset(360, 34),
@@ -159,25 +131,25 @@ local function showToast(msg: string)
toast.Visible = true
toastToken += 1
local myToken = toastToken
task.delay(2.6, function()
task.delay(2.4, function()
if toastToken == myToken then
toast.Visible = false
end
end)
end
-- ===== Garage / Dealership panel ===========================================
local garage = make("Frame", {
Name = "Garage",
Size = UDim2.fromOffset(560, 420),
Position = UDim2.new(0.5, -280, 0.5, -210),
-- Car picker panel ----------------------------------------------------------
local picker = make("Frame", {
Name = "CarPicker",
Size = UDim2.fromOffset(520, 400),
Position = UDim2.new(0.5, -260, 0.5, -200),
BackgroundColor3 = PANEL_BG,
BackgroundTransparency = 0.05,
Visible = false,
BorderSizePixel = 0,
}, screen)
corner(garage, 12)
make("UIStroke", { Color = NEON_BLUE, Thickness = 1.5, Transparency = 0.25 }, garage)
corner(picker, 12)
make("UIStroke", { Color = NEON_BLUE, Thickness = 1.5, Transparency = 0.25 }, picker)
make("TextLabel", {
Size = UDim2.new(1, 0, 0, 40),
@@ -185,8 +157,8 @@ make("TextLabel", {
Font = Enum.Font.GothamBold,
TextColor3 = Color3.fromRGB(255, 255, 255),
TextSize = 20,
Text = "GARAGE & DEALERSHIP",
}, garage)
Text = "PICK A CAR",
}, picker)
local closeBtn = make("TextButton", {
Size = UDim2.fromOffset(32, 32),
@@ -197,7 +169,7 @@ local closeBtn = make("TextButton", {
TextSize = 18,
Text = "X",
BorderSizePixel = 0,
}, garage)
}, picker)
corner(closeBtn, 8)
local list = make("ScrollingFrame", {
@@ -208,36 +180,27 @@ local list = make("ScrollingFrame", {
ScrollBarThickness = 6,
CanvasSize = UDim2.new(0, 0, 0, 0),
AutomaticCanvasSize = Enum.AutomaticSize.Y,
}, garage)
}, picker)
make("UIListLayout", { Padding = UDim.new(0, 8), SortOrder = Enum.SortOrder.LayoutOrder }, list)
-- Build one row per car; content depends on owned/equipped/affordable --------
local function rebuildGarage()
if not latest then
return
end
local function rebuildPicker()
for _, child in ipairs(list:GetChildren()) do
if child:IsA("Frame") then
child:Destroy()
end
end
local ownedIds: { [string]: any } = {}
for _, o in ipairs(latest.OwnedCars) do
ownedIds[o.Id] = o
end
for i, car in ipairs(Cars.List) do
local owned = ownedIds[car.Id]
local equipped = latest.EquippedCarId == car.Id
-- Hide restricted (allowlist-only) cars from anyone who doesn't own them.
if car.Restricted and not owned then
-- Hide the restricted RX-7 unless it's the car we're currently driving
-- (i.e. the server already granted it to us). The server enforces this
-- regardless of what the client shows.
if car.Restricted and currentCarId ~= car.Id then
continue
end
local isCurrent = currentCarId == car.Id
local row = make("Frame", {
Size = UDim2.new(1, 0, 0, 72),
Size = UDim2.new(1, 0, 0, 64),
BackgroundColor3 = Color3.fromRGB(26, 26, 38),
BorderSizePixel = 0,
LayoutOrder = i,
@@ -245,7 +208,7 @@ local function rebuildGarage()
corner(row, 8)
make("TextLabel", {
Size = UDim2.new(0.6, 0, 0, 22),
Size = UDim2.new(0.62, 0, 0, 22),
Position = UDim2.fromOffset(12, 8),
BackgroundTransparency = 1,
Font = Enum.Font.GothamBold,
@@ -256,7 +219,7 @@ local function rebuildGarage()
}, row)
make("TextLabel", {
Size = UDim2.new(0.6, 0, 0, 18),
Size = UDim2.new(0.62, 0, 0, 16),
Position = UDim2.fromOffset(12, 32),
BackgroundTransparency = 1,
Font = Enum.Font.Gotham,
@@ -272,125 +235,58 @@ local function rebuildGarage()
),
}, row)
-- Primary action button (Equip / Buy / locked) --------------------------
local action = make("TextButton", {
Size = UDim2.fromOffset(120, 30),
Position = UDim2.new(1, -132, 0, 10),
Size = UDim2.fromOffset(120, 34),
Position = UDim2.new(1, -132, 0.5, -17),
Font = Enum.Font.GothamBold,
TextSize = 13,
TextSize = 14,
BorderSizePixel = 0,
}, row)
corner(action, 6)
if owned then
if equipped then
action.Text = "EQUIPPED"
action.BackgroundColor3 = Color3.fromRGB(40, 60, 55)
action.TextColor3 = Color3.fromRGB(120, 255, 170)
else
action.Text = "EQUIP"
action.BackgroundColor3 = NEON_BLUE
action.TextColor3 = Color3.fromRGB(0, 0, 0)
action.MouseButton1Click:Connect(function()
local ok = rEquipCar:InvokeServer(car.Id)
if ok then
showToast("Equipped " .. car.Name)
end
end)
end
if isCurrent then
action.Text = "DRIVING"
action.BackgroundColor3 = Color3.fromRGB(40, 60, 55)
action.TextColor3 = Color3.fromRGB(120, 255, 170)
else
local affordable = latest.Cash >= car.Price and latest.Rep >= car.RepRequired
action.Text = string.format("BUY $%d", car.Price)
action.BackgroundColor3 = affordable and Color3.fromRGB(120, 255, 170) or Color3.fromRGB(60, 40, 48)
action.TextColor3 = affordable and Color3.fromRGB(0, 0, 0) or Color3.fromRGB(200, 120, 130)
action.Text = "DRIVE"
action.BackgroundColor3 = NEON_BLUE
action.TextColor3 = Color3.fromRGB(0, 0, 0)
action.MouseButton1Click:Connect(function()
local res = rBuyCar:InvokeServer(car.Id)
if res and res.ok then
showToast("Purchased " .. car.Name .. "!")
elseif res then
showToast(res.reason or "Cannot buy")
end
end)
end
-- Upgrade button (only for owned cars) ----------------------------------
if owned then
local upg = make("TextButton", {
Size = UDim2.fromOffset(120, 24),
Position = UDim2.new(1, -132, 0, 42),
Font = Enum.Font.GothamMedium,
TextSize = 12,
BackgroundColor3 = Color3.fromRGB(45, 45, 60),
TextColor3 = Color3.fromRGB(255, 210, 80),
BorderSizePixel = 0,
Text = string.format(
"UPGRADE (E%d/T%d/N%d)",
owned.Upgrades.Engine,
owned.Upgrades.Tires,
owned.Upgrades.Nitro
),
}, row)
corner(upg, 6)
upg.MouseButton1Click:Connect(function()
-- MVP: cycle Engine -> Tires -> Nitro whichever is lowest.
local parts = { "Engine", "Tires", "Nitro" }
table.sort(parts, function(a, b)
return owned.Upgrades[a] < owned.Upgrades[b]
end)
local res = rUpgradeCar:InvokeServer(car.Id, parts[1])
if res and res.ok then
showToast("Upgraded " .. parts[1] .. "!")
elseif res then
showToast(res.reason or "Cannot upgrade")
local ok = rEquipCar:InvokeServer(car.Id)
if ok then
showToast("Now driving " .. car.Name)
else
showToast("Can't drive that one")
end
end)
end
end
end
-- ===== Data sync ===========================================================
local function applyData(data: any)
latest = data
cashLabel.Text = string.format("$ %s", tostring(data.Cash))
repLabel.Text = string.format("REP %s", tostring(data.Rep))
local car = Cars.Get(data.EquippedCarId)
carLabel.Text = "Car: " .. (car and car.Name or data.EquippedCarId)
if garage.Visible then
rebuildGarage()
-- Data sync -----------------------------------------------------------------
rDataUpdated.OnClientEvent:Connect(function(carId: string)
currentCarId = carId
local car = Cars.Get(carId)
carLabel.Text = "Car: " .. (car and car.Name or carId)
if picker.Visible then
rebuildPicker()
end
end
end)
rDataUpdated.OnClientEvent:Connect(applyData)
-- ===== Wire buttons ========================================================
garageBtn.MouseButton1Click:Connect(function()
garage.Visible = not garage.Visible
if garage.Visible then
rebuildGarage()
-- Buttons -------------------------------------------------------------------
carsBtn.MouseButton1Click:Connect(function()
picker.Visible = not picker.Visible
if picker.Visible then
rebuildPicker()
end
end)
closeBtn.MouseButton1Click:Connect(function()
garage.Visible = false
picker.Visible = false
end)
spawnBtn.MouseButton1Click:Connect(function()
respawnBtn.MouseButton1Click:Connect(function()
rSpawnCar:FireServer()
showToast("Spawning your car...")
end)
raceBtn.MouseButton1Click:Connect(function()
local res = rStartRace:InvokeServer("expressway_timetrial")
if res and res.ok then
showToast("Race started! Reach the finish line!")
elseif res then
showToast(res.reason or "Cannot start race")
end
end)
dailyBtn.MouseButton1Click:Connect(function()
local res = rClaimDaily:InvokeServer()
if res and res.ok then
showToast(string.format("Daily: +$%d, +%d rep!", res.cash or 0, res.rep or 0))
elseif res then
showToast(res.reason or "Already claimed")
end
showToast("Respawning your car...")
end)
print("[FnF] Client UI ready.")
print("[FnF] Client UI ready (drive-only MVP).")
+5 -27
View File
@@ -9,8 +9,6 @@
the most reliable, least-buggy way to get a car moving on day one. When you
later import the real RX-7 mesh + a constraint chassis (A-Chassis style),
you swap out `buildCarModel` and keep everything else.
Upgrades scale MaxSpeed / Torque / TurnSpeed so the upgrade economy is real.
]]
local Players = game:GetService("Players")
@@ -20,10 +18,8 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Shared = ReplicatedStorage.Shared
local Cars = require(Shared.Cars)
local GameConfig = require(Shared.GameConfig)
local ProfileTemplate = require(Shared.ProfileTemplate)
local DataManager = require(script.Parent.DataManager)
type ProfileData = ProfileTemplate.ProfileData
type Car = Cars.Car
local CarManager = {}
@@ -31,17 +27,11 @@ local CarManager = {}
local activeCars: { [Player]: Model } = {}
local carsFolder: Folder
-- Convert a car's 0-100 stats + upgrade levels into VehicleSeat tuning --------
local function computeTuning(car: Car, upgrades: { Engine: number, Tires: number, Nitro: number })
local engineLvl = upgrades.Engine or 0
local tiresLvl = upgrades.Tires or 0
-- Base values derived from stats; upgrades add a percentage on top.
-- Convert a car's 0-100 stats into VehicleSeat tuning ------------------------
local function computeTuning(car: Car)
local maxSpeed = 60 + car.Stats.Speed * 1.6 -- studs/sec-ish target
maxSpeed *= (1 + engineLvl * 0.08) -- +8% top speed per engine level
local torque = 8000 + car.Stats.Acceleration * 220
torque *= (1 + engineLvl * 0.10)
local turnSpeed = 20 + car.Stats.Handling * 0.35
turnSpeed *= (1 + tiresLvl * 0.06) -- tires help turn-in
return maxSpeed, torque, turnSpeed
end
@@ -126,26 +116,14 @@ function CarManager.spawn(player: Player, carId: string?)
if not car then
return
end
-- Defense in depth: never spawn the restricted RX-7 for a non-allowlisted user,
-- even if their save somehow contained it.
if car.Id == GameConfig.RestrictedStarterCarId and not GameConfig.isRX7Allowed(player.UserId) then
return
end
-- Only spawn cars the player owns (anti-cheat: no spawning unowned exotics).
local owned
for _, o in ipairs(data.OwnedCars) do
if o.Id == id then
owned = o
break
end
end
if not owned then
-- 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 maxSpeed, torque, turnSpeed = computeTuning(car, owned.Upgrades)
local maxSpeed, torque, turnSpeed = computeTuning(car)
local model = buildCarModel(car, maxSpeed, torque, turnSpeed)
model:PivotTo(spawnCFrame())
model.Parent = carsFolder
+7 -13
View File
@@ -71,20 +71,14 @@ local function onPlayerAdded(player: Player)
profile:AddUserId(player.UserId) -- GDPR-friendly association
profile:Reconcile() -- fill missing template keys
-- Grant the correct starter car for THIS player (RX-7 only for allowlisted
-- UserIds; everyone else gets the public starter). Idempotent: only grants
-- if the player has no cars yet (i.e. genuinely first join).
-- Assign the player's default car if they don't have one yet, and validate:
-- if their saved car is the restricted RX-7 but they're no longer allowlisted,
-- fall back to the public default so they can still drive.
local data = profile.Data
if #data.OwnedCars == 0 then
local starterId = GameConfig.starterCarFor(player.UserId)
table.insert(data.OwnedCars, {
Id = starterId,
Upgrades = { Engine = 0, Tires = 0, Nitro = 0 },
})
data.EquippedCarId = starterId
elseif data.EquippedCarId == "" then
-- Safety: if somehow they own cars but have nothing equipped, equip the first.
data.EquippedCarId = data.OwnedCars[1].Id
if data.EquippedCarId == "" then
data.EquippedCarId = GameConfig.defaultCarFor(player.UserId)
elseif data.EquippedCarId == GameConfig.RestrictedCarId and not GameConfig.isRX7Allowed(player.UserId) then
data.EquippedCarId = GameConfig.PublicDefaultCarId
end
profile.OnSessionEnd:Connect(function()
-166
View File
@@ -1,166 +0,0 @@
--!strict
--[[
Economy.luau (SERVER)
The ONLY place cash/rep changes. Everything is server-authoritative: clients
can request actions but never set their own money. After any change we push a
fresh snapshot to the owning client via the DataUpdated remote.
Also owns: car buying, upgrades, and daily rewards — because those are all
economic transactions that must be validated server-side.
]]
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Shared = ReplicatedStorage.Shared
local GameConfig = require(Shared.GameConfig)
local Cars = require(Shared.Cars)
local ProfileTemplate = require(Shared.ProfileTemplate)
local DataManager = require(script.Parent.DataManager)
type ProfileData = ProfileTemplate.ProfileData
type Result = { ok: boolean, reason: string? }
local Economy = {}
-- Upgrade tuning: cost scales, effect has diminishing returns -----------------
local MAX_UPGRADE_LEVEL = 5
local UPGRADE_BASE_COST = 500
local UPGRADE_COST_GROWTH = 1.6 -- each level costs 1.6x the previous
local function upgradeCost(currentLevel: number): number
return math.floor(UPGRADE_BASE_COST * (UPGRADE_COST_GROWTH ^ currentLevel))
end
Economy.upgradeCost = upgradeCost
Economy.MAX_UPGRADE_LEVEL = MAX_UPGRADE_LEVEL
-- Push a snapshot to the client (thin: just the data table) ------------------
local dataUpdatedRemote: RemoteEvent? = nil
function Economy._setRemote(remote: RemoteEvent)
dataUpdatedRemote = remote
end
local function pushUpdate(player: Player, data: ProfileData)
if dataUpdatedRemote then
dataUpdatedRemote:FireClient(player, data)
end
end
Economy.pushUpdate = pushUpdate
-- Core mutators --------------------------------------------------------------
function Economy.addCash(player: Player, amount: number): boolean
local data = DataManager.get(player)
if not data then
return false
end
data.Cash = math.max(0, data.Cash + amount)
pushUpdate(player, data)
return true
end
function Economy.addRep(player: Player, amount: number): boolean
local data = DataManager.get(player)
if not data then
return false
end
data.Rep = math.max(0, data.Rep + amount)
pushUpdate(player, data)
return true
end
local function ownsCar(data: ProfileData, carId: string): boolean
for _, owned in ipairs(data.OwnedCars) do
if owned.Id == carId then
return true
end
end
return false
end
Economy.ownsCar = ownsCar
-- Buying a car ---------------------------------------------------------------
function Economy.buyCar(player: Player, carId: string): Result
local data = DataManager.get(player)
if not data then
return { ok = false, reason = "Data not loaded" }
end
local car = Cars.Get(carId)
if not car then
return { ok = false, reason = "Unknown car" }
end
-- Restricted demo car: the RX-7 can only ever be owned by allowlisted UserIds.
if car.Id == GameConfig.RestrictedStarterCarId and not GameConfig.isRX7Allowed(player.UserId) then
return { ok = false, reason = "This car is not available" }
end
if ownsCar(data, carId) then
return { ok = false, reason = "You already own this car" }
end
if data.Rep < car.RepRequired then
return { ok = false, reason = string.format("Requires %d rep", car.RepRequired) }
end
if data.Cash < car.Price then
return { ok = false, reason = "Not enough cash" }
end
data.Cash -= car.Price
table.insert(data.OwnedCars, { Id = carId, Upgrades = { Engine = 0, Tires = 0, Nitro = 0 } })
pushUpdate(player, data)
return { ok = true }
end
-- Upgrading a car part -------------------------------------------------------
local VALID_PARTS = { Engine = true, Tires = true, Nitro = true }
function Economy.upgradeCar(player: Player, carId: string, part: string): Result
local data = DataManager.get(player)
if not data then
return { ok = false, reason = "Data not loaded" }
end
if not VALID_PARTS[part] then
return { ok = false, reason = "Invalid part" }
end
local owned
for _, o in ipairs(data.OwnedCars) do
if o.Id == carId then
owned = o
break
end
end
if not owned then
return { ok = false, reason = "You don't own this car" }
end
local level = owned.Upgrades[part] or 0
if level >= MAX_UPGRADE_LEVEL then
return { ok = false, reason = "Already maxed" }
end
local cost = upgradeCost(level)
if data.Cash < cost then
return { ok = false, reason = "Not enough cash" }
end
data.Cash -= cost
owned.Upgrades[part] = level + 1
pushUpdate(player, data)
return { ok = true }
end
-- Daily reward ---------------------------------------------------------------
function Economy.claimDaily(player: Player): Result & { cash: number?, rep: number? }
local data = DataManager.get(player)
if not data then
return { ok = false, reason = "Data not loaded" }
end
local now = os.time()
if now - data.LastDailyClaim < GameConfig.DailyCooldownSeconds then
local remaining = GameConfig.DailyCooldownSeconds - (now - data.LastDailyClaim)
local hours = math.ceil(remaining / 3600)
return { ok = false, reason = string.format("Come back in ~%dh", hours) }
end
data.LastDailyClaim = now
data.Cash += GameConfig.DailyRewardCash
data.Rep += GameConfig.DailyRewardRep
pushUpdate(player, data)
return { ok = true, cash = GameConfig.DailyRewardCash, rep = GameConfig.DailyRewardRep }
end
return Economy
-107
View File
@@ -1,107 +0,0 @@
--!strict
--[[
RaceManager.luau (SERVER)
MVP race: a solo time-trial down the expressway. Player starts a race, drives
to the FinishLine, and the SERVER validates the finish (touch of the real
finish part) before paying out. This keeps rewards server-authoritative — the
client can SAY it won, but it only gets paid if the server saw the touch.
This is intentionally the simplest thing that proves the race->reward->save
loop. Head-to-head, checkpoints, and drag mode come later.
]]
local Players = game:GetService("Players")
local Workspace = game:GetService("Workspace")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Shared = ReplicatedStorage.Shared
local GameConfig = require(Shared.GameConfig)
local Economy = require(script.Parent.Economy)
local DataManager = require(script.Parent.DataManager)
local RaceManager = {}
type ActiveRace = {
startTime: number,
connection: RBXScriptConnection,
}
local active: { [Player]: ActiveRace } = {}
local RACE_TIMEOUT = 180 -- auto-cancel a race after 3 min
local function finishFor(player: Player, won: boolean)
local race = active[player]
if not race then
return
end
race.connection:Disconnect()
active[player] = nil
local data = DataManager.get(player)
if not data then
return
end
data.TotalRaces += 1
local payout = GameConfig.RaceBaseReward
if won then
payout += GameConfig.RaceWinBonus
data.Wins += 1
end
Economy.addCash(player, payout)
Economy.addRep(player, GameConfig.RaceRepReward)
-- addCash/addRep already push updates; TotalRaces/Wins ride along on next push.
Economy.pushUpdate(player, data)
end
--- Start a race for the player. Returns a Result.
function RaceManager.start(player: Player, _raceId: string?): { ok: boolean, reason: string? }
if active[player] then
return { ok = false, reason = "Already racing" }
end
local map = Workspace:FindFirstChild("Map")
local finish = map and map:FindFirstChild("FinishLine")
if not (finish and finish:IsA("BasePart")) then
return { ok = false, reason = "No finish line in map" }
end
local conn
conn = finish.Touched:Connect(function(hit)
-- Was this the racing player's car/character that touched the line?
local character = player.Character
local carModel = hit:FindFirstAncestorOfClass("Model")
local isOwnCar = carModel and carModel:GetAttribute("OwnerUserId") == player.UserId
local isSelf = character and hit:IsDescendantOf(character)
if isOwnCar or isSelf then
finishFor(player, true)
end
end)
active[player] = { startTime = os.clock(), connection = conn }
-- Timeout guard
task.delay(RACE_TIMEOUT, function()
if active[player] and os.clock() - active[player].startTime >= RACE_TIMEOUT then
-- cancel with no reward (won = false but we just clean up)
local race = active[player]
if race then
race.connection:Disconnect()
active[player] = nil
end
end
end)
return { ok = true }
end
function RaceManager.init()
Players.PlayerRemoving:Connect(function(player)
local race = active[player]
if race then
race.connection:Disconnect()
active[player] = nil
end
end)
end
return RaceManager
+31 -59
View File
@@ -1,13 +1,14 @@
--!strict
--[[
init.server.luau (SERVER ENTRY POINT)
The one script Roblox runs on the server. It:
MVP scope: JUST DRIVING. The server:
1. Creates the remotes
2. Boots every manager in dependency order
3. Builds the map + lighting mood
4. Wires all remote handlers to server-authoritative logic
2. Boots DataManager (safe saves), CarManager, AntiCheat
3. Builds the expressway map + lighting mood
4. Gives each player their car and lets them switch cars
Everything downstream is server-authoritative: clients REQUEST, server DECIDES.
Server-authoritative: the client REQUESTS a car, the server DECIDES (and
enforces the RX-7 allowlist).
]]
local Players = game:GetService("Players")
@@ -15,11 +16,11 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Shared = ReplicatedStorage.Shared
local Remotes = require(Shared.Remotes)
local Cars = require(Shared.Cars)
local GameConfig = require(Shared.GameConfig)
local DataManager = require(script.DataManager)
local Economy = require(script.Economy)
local CarManager = require(script.CarManager)
local RaceManager = require(script.RaceManager)
local MapBuilder = require(script.MapBuilder)
local AntiCheat = require(script.AntiCheat)
@@ -28,29 +29,28 @@ Remotes.init()
local rDataUpdated = Remotes.get("DataUpdated") :: RemoteEvent
local rSpawnCar = Remotes.get("SpawnCar") :: RemoteEvent
local rEquipCar = Remotes.get("EquipCar") :: RemoteFunction
local rBuyCar = Remotes.get("BuyCar") :: RemoteFunction
local rUpgradeCar = Remotes.get("UpgradeCar") :: RemoteFunction
local rClaimDaily = Remotes.get("ClaimDaily") :: RemoteFunction
local rStartRace = Remotes.get("StartRace") :: RemoteFunction
Economy._setRemote(rDataUpdated)
local function pushCar(player: Player)
local data = DataManager.get(player)
if data then
rDataUpdated:FireClient(player, data.EquippedCarId)
end
end
-- 2. Boot managers ----------------------------------------------------------
DataManager.init()
CarManager.init()
RaceManager.init()
AntiCheat.init()
-- 3. Build the world --------------------------------------------------------
MapBuilder.build()
-- 4. Player lifecycle: push initial data + auto-spawn their car -------------
-- 4. Player lifecycle: push car id + auto-spawn their car -------------------
Players.PlayerAdded:Connect(function(player)
DataManager.onReady(player, function(data)
Economy.pushUpdate(player, data)
DataManager.onReady(player, function()
pushCar(player)
end)
player.CharacterAdded:Connect(function()
-- When the character (re)spawns, give them their equipped car after a beat.
DataManager.onReady(player, function(data)
task.wait(1)
CarManager.spawn(player, data.EquippedCarId)
@@ -58,59 +58,31 @@ Players.PlayerAdded:Connect(function(player)
end)
end)
-- 5. Remote handlers (all validated server-side) ----------------------------
rSpawnCar.OnServerEvent:Connect(function(player, carId)
if carId ~= nil and typeof(carId) ~= "string" then
return
end
CarManager.spawn(player, carId)
-- 5. Remote handlers (validated server-side) --------------------------------
rSpawnCar.OnServerEvent:Connect(function(player)
CarManager.spawn(player) -- re-spawn current car (e.g. after flipping it)
end)
rEquipCar.OnServerInvoke = function(player, carId)
if typeof(carId) ~= "string" then
return false
end
local car = Cars.Get(carId)
if not car then
return false
end
-- Enforce the RX-7 allowlist server-side.
if not GameConfig.canDrive(player.UserId, car.Id, car.Restricted) then
return false
end
local data = DataManager.get(player)
if not data or not Economy.ownsCar(data, carId) then
if not data then
return false
end
data.EquippedCarId = carId
Economy.pushUpdate(player, data)
pushCar(player)
CarManager.spawn(player, carId)
return true
end
rBuyCar.OnServerInvoke = function(player, carId)
if typeof(carId) ~= "string" then
return { ok = false, reason = "Bad request" }
end
return Economy.buyCar(player, carId)
end
rUpgradeCar.OnServerInvoke = function(player, carId, part)
if typeof(carId) ~= "string" or typeof(part) ~= "string" then
return { ok = false, reason = "Bad request" }
end
local result = Economy.upgradeCar(player, carId, part)
-- Re-spawn so upgraded tuning takes effect immediately if it's the active car.
if result.ok then
local data = DataManager.get(player)
if data and data.EquippedCarId == carId then
CarManager.spawn(player, carId)
end
end
return result
end
rClaimDaily.OnServerInvoke = function(player)
return Economy.claimDaily(player)
end
rStartRace.OnServerInvoke = function(player, raceId)
if raceId ~= nil and typeof(raceId) ~= "string" then
return { ok = false, reason = "Bad request" }
end
return RaceManager.start(player, raceId)
end
print("[FnF] Server booted: managers online, map built, remotes wired.")
print("[FnF] Server booted (drive-only MVP): map built, car system online.")
+12 -30
View File
@@ -1,13 +1,11 @@
--!strict
--[[
Cars.luau
The car catalogue. Table-driven so adding a car = adding a row.
MVP ships with the RX-7 starter + a few tier examples so the dealership
and progression have something to show. Expand freely later.
The car catalogue. MVP scope (just driving): a car is an identity + driving
feel + look. No prices, no rep gates, no upgrades. Add a car = add a row.
Stat scale: 0-100 (relative feel, not real units). Speed maps to top speed,
Acceleration to how fast it gets there, Handling to grip/turn-in.
Price is in cash. RepRequired gates purchase behind reputation.
Stat scale 0-100 (relative feel): Speed = top speed, Acceleration = how fast
it gets there, Handling = grip/turn-in. These drive the VehicleSeat tuning.
]]
export type CarStats = {
@@ -20,31 +18,24 @@ export type Car = {
Id: string,
Name: string,
Model: string, -- real-world reference
Tier: number, -- 1 Junk .. 5 Exotic
Price: number,
RepRequired: number,
Tier: number, -- 1 Junk .. 5 Exotic (display/flavour only in MVP)
Stats: CarStats,
Color: { number }, -- {r,g,b} 0-1 for the placeholder body until real mesh exists
Color: { number }, -- {r,g,b} 0-1 for the placeholder body until a real mesh exists
Description: string,
Starter: boolean?, -- true = granted free on first join
Restricted: boolean?, -- true = allowlist-only; hidden from dealership for others
Restricted: boolean?, -- true = allowlist-only (the RX-7 demo lock)
}
local Cars = {}
-- Ordered list (dealership browses this in order) ---------------------------
Cars.List = {
{
Id = "rx7_fc",
Name = "Chick3n's RX-7",
Model = "1993 Mazda RX-7 (FD)",
Tier = 2,
Price = 0,
RepRequired = 0,
Stats = { Speed = 62, Acceleration = 58, Handling = 70 },
Color = { 0.85, 0.15, 0.12 }, -- classic RX-7 red until the real mesh is imported
Description = "The one that started it all. A rotary-hearted FD RX-7 — light, balanced, and iconic. Your daily driver and first weapon on the expressway.",
Starter = true,
Description = "The one that started it all. A rotary-hearted FD RX-7 — light, balanced, iconic.",
Restricted = true, -- demo-locked to the owner + son during testing
},
{
@@ -52,48 +43,39 @@ Cars.List = {
Name = "City Runabout",
Model = "90s econobox hatchback",
Tier = 1,
Price = 800,
RepRequired = 0,
Stats = { Speed = 34, Acceleration = 40, Handling = 48 },
Color = { 0.5, 0.5, 0.55 },
Description = "Cheap, cheerful, and slow. A backup beater for when you want to grind pocket change.",
Description = "Cheap, cheerful, and slow. The public default cruiser.",
},
{
Id = "silv_s15",
Name = "Night Slider",
Model = "Nissan Silvia S15",
Tier = 3,
Price = 12000,
RepRequired = 50,
Stats = { Speed = 74, Acceleration = 70, Handling = 78 },
Color = { 0.1, 0.35, 0.85 },
Description = "A drift-bred coupe with serious mid-corner poise. The natural step up from the RX-7.",
Description = "A drift-bred coupe with serious mid-corner poise.",
},
{
Id = "gtr_r34",
Name = "Godzilla",
Model = "Nissan Skyline GT-R R34",
Tier = 4,
Price = 45000,
RepRequired = 180,
Stats = { Speed = 88, Acceleration = 86, Handling = 82 },
Color = { 0.15, 0.6, 0.95 },
Description = "AWD, twin-turbo, legendary. When you show up in this, people notice.",
Description = "AWD, twin-turbo, legendary. People notice when you show up in this.",
},
{
Id = "hyper_x",
Name = "Apex Phantom",
Model = "Exotic hypercar (stylized)",
Tier = 5,
Price = 250000,
RepRequired = 600,
Stats = { Speed = 100, Acceleration = 98, Handling = 90 },
Color = { 0.05, 0.05, 0.06 },
Description = "Endgame flex. Millions of yen of carbon fibre and neon. The top of the food chain.",
Description = "Top of the food chain. Carbon fibre and neon.",
},
}
-- Fast lookup by Id (built once) --------------------------------------------
Cars.ById = {} :: { [string]: Car }
for _, car in ipairs(Cars.List) do
Cars.ById[car.Id] = car
+25 -34
View File
@@ -1,63 +1,54 @@
--!strict
--[[
GameConfig.luau
Central tunable constants. Anything you might want to balance later lives here,
NOT hard-coded in systems. Single source of truth for numbers.
Central tunable constants. MVP scope: JUST DRIVING on the expressway.
No racing, no economy, no rewards — pick a car and drive it.
]]
local GameConfig = {}
-- Economy -------------------------------------------------------------------
GameConfig.StartingCash = 1500 -- enough to feel, not enough to skip the RX-7
GameConfig.StartingRep = 0
-- Starter cars ---------------------------------------------------------------
-- Starter / default cars -----------------------------------------------------
-- The RX-7 is a RESTRICTED demo car for the owner + son during testing. Only
-- UserIds in the allowlist below ever receive / can buy / can spawn it.
-- Everyone else starts in the public starter car instead.
GameConfig.PublicStarterCarId = "hatch_junk" -- default car for non-allowlisted players
GameConfig.RestrictedStarterCarId = "rx7_fc" -- the real 1993 Mazda RX-7 (demo-locked)
-- UserIds in the allowlist below can drive it. Everyone else gets the public
-- default and can drive any non-restricted car via the car picker.
GameConfig.PublicDefaultCarId = "hatch_junk" -- default equipped car for non-allowlisted players
GameConfig.RestrictedCarId = "rx7_fc" -- the real 1993 Mazda RX-7 (demo-locked)
-- >>> FILL THESE IN: Roblox UserIds allowed to own the RX-7 (numbers, not names).
-- Roblox UserIds allowed to drive the restricted RX-7 (numbers, not names).
-- Find yours at roblox.com/users/<name>/profile — the number in the URL.
GameConfig.RX7AllowedUserIds = {
[459891257] = true, -- R3AL.CH1CK3N (owner)
[4731701178] = true, -- son
}
--- True if this player is allowed to own the restricted RX-7.
--- True if this player is allowed to drive the restricted RX-7.
function GameConfig.isRX7Allowed(userId: number): boolean
return GameConfig.RX7AllowedUserIds[userId] == true
end
--- The starter car id this specific player should receive on first join.
function GameConfig.starterCarFor(userId: number): string
--- The default car this player should be driving on first join.
function GameConfig.defaultCarFor(userId: number): string
if GameConfig.isRX7Allowed(userId) then
return GameConfig.RestrictedStarterCarId
return GameConfig.RestrictedCarId
end
return GameConfig.PublicStarterCarId
return GameConfig.PublicDefaultCarId
end
-- Race rewards (flat MVP model; tune per-race later) -------------------------
GameConfig.RaceBaseReward = 250
GameConfig.RaceWinBonus = 400
GameConfig.RaceRepReward = 15
-- Daily login ----------------------------------------------------------------
GameConfig.DailyRewardCash = 300
GameConfig.DailyRewardRep = 10
GameConfig.DailyCooldownSeconds = 20 * 60 * 60 -- 20h so a "daily" is reliably claimable each day
-- Anti-cheat sanity bounds (server-side validation) --------------------------
-- Max believable straight-line speed in studs/sec for the FASTEST car + boosts.
-- Anything sustained above this => flagged. Generous to avoid false positives in MVP.
GameConfig.MaxPlausibleSpeed = 400
--- Can this player drive this car? Restricted cars need the allowlist.
--- (carId kept in the signature for call-site clarity / future per-car rules.)
function GameConfig.canDrive(userId: number, _carId: string, restricted: boolean?): boolean
if restricted then
return GameConfig.isRX7Allowed(userId)
end
return true
end
-- DataStore ------------------------------------------------------------------
GameConfig.ProfileStoreName = "PlayerData_v1" -- bump the _vN to wipe/migrate in dev
GameConfig.UseMockInStudio = true -- ProfileStore mock store when testing in Studio (no live DataStore)
GameConfig.UseMockInStudio = true -- ProfileStore mock store when testing in Studio
-- Autosave interval handled by ProfileStore internally; kept here for reference.
GameConfig.SessionTag = "fnf-sim-mvp"
-- Anti-cheat sanity bound (server-side): max believable horizontal speed
-- (studs/sec) for the fastest car. Sustained above this => snap back.
GameConfig.MaxPlausibleSpeed = 400
return GameConfig
+6 -35
View File
@@ -1,48 +1,19 @@
--!strict
--[[
ProfileTemplate.luau
The canonical shape of a player's saved data. ProfileStore fills any
missing keys from this template automatically (safe forward-migration:
add a key here and existing players get the default on next load).
NEVER remove/rename a key without a migration plan — that's how saves break.
Saved data shape. MVP scope (just driving): we only remember which car the
player last drove, so they resume in it. No money, no owned-car list, no rep.
ProfileStore reconciles missing keys from this template automatically.
]]
local GameConfig = require(script.Parent.GameConfig)
export type UpgradeLevels = {
Engine: number,
Tires: number,
Nitro: number,
}
export type OwnedCar = {
Id: string,
Upgrades: UpgradeLevels,
}
export type ProfileData = {
Cash: number,
Rep: number,
OwnedCars: { OwnedCar },
EquippedCarId: string,
LastDailyClaim: number, -- os.time() of last claim; 0 = never
TotalRaces: number,
Wins: number,
Version: number, -- schema version for future migrations
EquippedCarId: string, -- last car the player was driving ("" = assign default on load)
Version: number,
}
local ProfileTemplate: ProfileData = {
Cash = GameConfig.StartingCash,
Rep = GameConfig.StartingRep,
-- Starter car is granted per-player by DataManager after load (depends on
-- UserId: allowlisted players get the RX-7, everyone else the public car).
OwnedCars = {},
EquippedCarId = "",
LastDailyClaim = 0,
TotalRaces = 0,
Wins = 0,
Version = 1,
Version = 2,
}
return ProfileTemplate
+6 -13
View File
@@ -2,12 +2,10 @@
--[[
Remotes.luau
Single source of truth for every RemoteEvent / RemoteFunction.
The SERVER calls Remotes.init() once at boot to create the instances under
ReplicatedStorage/Remotes. The CLIENT just requires and reads them (they're
guaranteed to exist because the server script runs first / it waits).
MVP scope (just driving): the only client->server actions are "give me a car"
and "switch which car I'm driving". Server creates these at boot; client reads.
Add a remote = add one line to the DEFS table. No stringly-typed
FindFirstChild scattered across the codebase.
Add a remote = add one line to DEFS. No stringly-typed FindFirstChild elsewhere.
]]
local ReplicatedStorage = game:GetService("ReplicatedStorage")
@@ -17,17 +15,12 @@ local Remotes = {}
-- name -> class. "Event" = RemoteEvent, "Function" = RemoteFunction ----------
local DEFS: { [string]: string } = {
-- Server -> Client: pushes fresh profile snapshot for HUD/garage
-- Server -> Client: pushes the player's current car id (for HUD)
DataUpdated = "Event",
-- Client -> Server requests
SpawnCar = "Event", -- (carId?) spawn/replace the player's equipped car
EquipCar = "Function", -- (carId) -> boolean success
BuyCar = "Function", -- (carId) -> {ok:boolean, reason:string?}
UpgradeCar = "Function", -- (carId, part) -> {ok:boolean, reason:string?}
ClaimDaily = "Function", -- () -> {ok:boolean, cash:number?, rep:number?, reason:string?}
StartRace = "Function", -- (raceId) -> {ok:boolean, reason:string?}
FinishRace = "Event", -- (raceId, won:boolean) client reports; server validates
SpawnCar = "Event", -- () re-spawn the player's current car (e.g. after a flip)
EquipCar = "Function", -- (carId) -> boolean: switch to & spawn a different car
}
local FOLDER_NAME = "Remotes"