Initial MVP: drivable RX-7 vertical slice + server-authoritative systems
CI / quality (push) Failing after 5m53s

- Rojo + Wally project; ProfileStore (loleris) for safe saves
- Shared: GameConfig, Cars (RX-7 free starter), ProfileTemplate, Remotes
- Server: DataManager, Economy, CarManager, RaceManager, MapBuilder, AntiCheat
- Client: neon HUD + Garage/Dealership UI (buy/equip/upgrade), race/daily
- Procedural Tokyo expressway (spawn pad, finish line, neon pylons, kill walls)
- CI workflow (Gitea/GitHub), Makefile, selene+stylua clean, rojo builds
- Docs: README, GDD, realistic solo ROADMAP
This commit is contained in:
2026-07-21 23:30:53 -04:00
commit 9a2f2d4dc9
24 changed files with 1986 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
--!strict
--[[
AntiCheat.luau (SERVER)
MVP-level movement sanity check. Not a fortress — a speed bump that catches
obvious speed/teleport hacks. Because money & car ownership are ALREADY
server-authoritative (Economy validates everything), the main client attack
surface left is movement, so we watch that.
Approach: sample each player's HRP position periodically; if they cover
ground implying a sustained speed above MaxPlausibleSpeed, snap them back
to their last valid position. Generous threshold to avoid false positives
from legit fast cars.
]]
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local GameConfig = require(ReplicatedStorage.Shared.GameConfig)
local AntiCheat = {}
type Track = { lastPos: Vector3, lastValid: CFrame }
local tracked: { [Player]: Track } = {}
local SAMPLE_INTERVAL = 0.5
local function hrpOf(player: Player): BasePart?
local char = player.Character
return char and char:FindFirstChild("HumanoidRootPart") :: BasePart?
end
function AntiCheat.init()
local function watch(player: Player)
player.CharacterAdded:Connect(function(char)
local hrp = char:WaitForChild("HumanoidRootPart", 10) :: BasePart?
if hrp then
tracked[player] = { lastPos = hrp.Position, lastValid = hrp.CFrame }
end
end)
end
for _, p in ipairs(Players:GetPlayers()) do
watch(p)
end
Players.PlayerAdded:Connect(watch)
Players.PlayerRemoving:Connect(function(p)
tracked[p] = nil
end)
local accum = 0
RunService.Heartbeat:Connect(function(dt)
accum += dt
if accum < SAMPLE_INTERVAL then
return
end
local interval = accum
accum = 0
for player, track in pairs(tracked) do
local hrp = hrpOf(player)
if not hrp then
continue
end
local pos = hrp.Position
-- Falling doesn't count (avoid flagging big drops). Only guard XZ plane.
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
-- Snap back: likely a speed/teleport hack.
hrp.CFrame = track.lastValid
hrp.AssemblyLinearVelocity = Vector3.zero
else
track.lastValid = hrp.CFrame
end
track.lastPos = hrp.Position
end
end)
end
return AntiCheat
+178
View File
@@ -0,0 +1,178 @@
--!strict
--[[
CarManager.luau (SERVER)
Spawns the player's equipped car as a simple, PROVEN drivable vehicle.
Design choice (per "don't reinvent the wheel"): instead of a hand-rolled
physics chassis, the MVP car is a single driving Part + a VehicleSeat.
Roblox's VehicleSeat drives the assembly via its built-in Throttle/Steer —
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")
local Workspace = game:GetService("Workspace")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Shared = ReplicatedStorage.Shared
local Cars = require(Shared.Cars)
local ProfileTemplate = require(Shared.ProfileTemplate)
local DataManager = require(script.Parent.DataManager)
type ProfileData = ProfileTemplate.ProfileData
type Car = Cars.Car
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.
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
-- Build the placeholder car model. Returns the Model with a VehicleSeat.
local function buildCarModel(car: Car, maxSpeed: number, torque: number, turnSpeed: number): Model
local model = Instance.new("Model")
model.Name = "Car_" .. car.Id
-- Body (the driving part) --------------------------------------------------
local body = Instance.new("Part")
body.Name = "Body"
body.Size = Vector3.new(6, 2, 12)
body.Color = Color3.new(car.Color[1], car.Color[2], car.Color[3])
body.Material = Enum.Material.SmoothPlastic
body.CanCollide = true
body.Parent = model
-- A cabin block so it reads as a car, welded to the body -------------------
local cabin = Instance.new("Part")
cabin.Name = "Cabin"
cabin.Size = Vector3.new(5, 1.6, 5)
cabin.Color = Color3.new(car.Color[1] * 0.7, car.Color[2] * 0.7, car.Color[3] * 0.7)
cabin.Material = Enum.Material.Glass
cabin.CanCollide = false
cabin.Parent = model
local cabinWeld = Instance.new("WeldConstraint")
cabinWeld.Part0 = body
cabinWeld.Part1 = cabin
cabinWeld.Parent = body
cabin.CFrame = body.CFrame * CFrame.new(0, 1.8, 0.5)
-- VehicleSeat: Roblox's built-in, reliable driver ------------------------
local seat = Instance.new("VehicleSeat")
seat.Name = "DriveSeat"
seat.Size = Vector3.new(4, 0.5, 3)
seat.MaxSpeed = maxSpeed
seat.Torque = torque
seat.TurnSpeed = turnSpeed
seat.HeadsUpDisplay = false
seat.CanCollide = false
seat.Color = Color3.new(0.1, 0.1, 0.1)
seat.Parent = model
local seatWeld = Instance.new("WeldConstraint")
seatWeld.Part0 = body
seatWeld.Part1 = seat
seatWeld.Parent = body
seat.CFrame = body.CFrame * CFrame.new(0, 1.6, -1)
model.PrimaryPart = body
return model
end
-- Find a reasonable spawn CFrame (a SpawnPad in the map, else origin) --------
local function spawnCFrame(): CFrame
local map = Workspace:FindFirstChild("Map")
if map then
local pad = map:FindFirstChild("SpawnPad", true)
if pad and pad:IsA("BasePart") then
return pad.CFrame * CFrame.new(0, 5, 0)
end
end
return CFrame.new(0, 8, 0)
end
--- Despawn the player's current car if any.
function CarManager.despawn(player: Player)
local existing = activeCars[player]
if existing then
existing:Destroy()
activeCars[player] = nil
end
end
--- Spawn (or replace) the player's equipped car and seat them in it.
function CarManager.spawn(player: Player, carId: string?)
local data = DataManager.get(player)
if not data then
return
end
local id = carId or data.EquippedCarId
local car = Cars.Get(id)
if not car 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
return
end
CarManager.despawn(player)
local maxSpeed, torque, turnSpeed = computeTuning(car, owned.Upgrades)
local model = buildCarModel(car, maxSpeed, torque, turnSpeed)
model:PivotTo(spawnCFrame())
model.Parent = carsFolder
model:SetAttribute("OwnerUserId", player.UserId)
model:SetAttribute("CarId", id)
-- Sit the player down. Give the assembly network ownership to the driver so
-- physics are smooth on their client (standard Roblox vehicle practice).
local character = player.Character
if character then
local seat = model:FindFirstChild("DriveSeat") :: VehicleSeat
local body = model.PrimaryPart
if body then
body:SetNetworkOwner(player)
end
if seat then
task.defer(function()
seat:Sit(character:FindFirstChildOfClass("Humanoid"))
end)
end
end
activeCars[player] = model
end
function CarManager.init()
carsFolder = Instance.new("Folder")
carsFolder.Name = "ActiveCars"
carsFolder.Parent = Workspace
Players.PlayerRemoving:Connect(function(player)
CarManager.despawn(player)
end)
end
return CarManager
+151
View File
@@ -0,0 +1,151 @@
--!strict
--[[
DataManager.luau (SERVER)
Wraps loleris's ProfileStore (battle-tested) so the rest of the game never
touches DataStores directly. This is deliberately built FIRST — data safety
is the foundation everything else stands on.
Public API:
DataManager.init()
DataManager.get(player) -> ProfileData? (nil if still loading / kicked)
DataManager.onReady(player, fn) -> runs fn(data) now or when loaded
DataManager.forEachLoaded(fn)
Guarantees:
- Session locking (no data loss from double-load across servers)
- Reconcile fills new template keys onto old saves (forward migration)
- Studio uses ProfileStore.Mock so you can test without a live DataStore
]]
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local ServerScriptService = game:GetService("ServerScriptService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ProfileStore = require(ServerScriptService.ServerPackages.ProfileStore)
local Shared = ReplicatedStorage.Shared
local GameConfig = require(Shared.GameConfig)
local ProfileTemplate = require(Shared.ProfileTemplate)
type ProfileData = ProfileTemplate.ProfileData
local DataManager = {}
-- Profiles keyed by player. Value is the ProfileStore profile object.
local profiles: { [Player]: any } = {}
-- Callbacks queued before a profile finished loading.
local readyQueue: { [Player]: { (ProfileData) -> () } } = {}
local store: any = nil
local function keyFor(player: Player): string
return "Player_" .. tostring(player.UserId)
end
local function flushReady(player: Player, data: ProfileData)
local queue = readyQueue[player]
if not queue then
return
end
readyQueue[player] = nil
for _, fn in ipairs(queue) do
task.spawn(fn, data)
end
end
local function onPlayerAdded(player: Player)
local profile = store:StartSessionAsync(keyFor(player), {
Cancel = function()
-- Give up starting the session if the player already left.
return player.Parent ~= Players
end,
})
if profile == nil then
-- Another server holds the lock, or player left mid-load. Kick to be safe.
player:Kick("[FnF] Your data could not be loaded. Please rejoin.")
return
end
profile:AddUserId(player.UserId) -- GDPR-friendly association
profile:Reconcile() -- fill missing template keys
profile.OnSessionEnd:Connect(function()
-- The session was lost (another server stole it, or we released it).
profiles[player] = nil
if player.Parent == Players then
player:Kick("[FnF] Your session ended (data loaded elsewhere). Please rejoin.")
end
end)
if player.Parent ~= Players then
-- Player left during the yield above; release immediately.
profile:EndSession()
return
end
profiles[player] = profile
flushReady(player, profile.Data)
end
local function onPlayerRemoving(player: Player)
local profile = profiles[player]
if profile then
profile:EndSession() -- saves + releases the lock
end
profiles[player] = nil
readyQueue[player] = nil
end
--- Returns the live, mutable data table (changes autosave). nil while loading.
function DataManager.get(player: Player): ProfileData?
local profile = profiles[player]
return profile and profile.Data or nil
end
--- Run fn with the player's data now (if loaded) or as soon as it loads.
function DataManager.onReady(player: Player, fn: (ProfileData) -> ())
local profile = profiles[player]
if profile then
task.spawn(fn, profile.Data)
return
end
readyQueue[player] = readyQueue[player] or {}
table.insert(readyQueue[player], fn)
end
--- Iterate every currently-loaded player's data.
function DataManager.forEachLoaded(fn: (Player, ProfileData) -> ())
for player, profile in pairs(profiles) do
fn(player, profile.Data)
end
end
function DataManager.init()
assert(RunService:IsServer(), "DataManager is server-only")
local base = ProfileStore.New(GameConfig.ProfileStoreName, ProfileTemplate)
-- In Studio, use the in-memory Mock store so testing never touches live data.
if GameConfig.UseMockInStudio and RunService:IsStudio() then
store = base.Mock
else
store = base
end
for _, player in ipairs(Players:GetPlayers()) do
task.spawn(onPlayerAdded, player)
end
Players.PlayerAdded:Connect(onPlayerAdded)
Players.PlayerRemoving:Connect(onPlayerRemoving)
-- Ensure clean release on shutdown (BindToClose is handled by ProfileStore too,
-- but ending sessions explicitly is tidy for the last frame).
game:BindToClose(function()
for _, player in ipairs(Players:GetPlayers()) do
onPlayerRemoving(player)
end
end)
end
return DataManager
+162
View File
@@ -0,0 +1,162 @@
--!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
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
+142
View File
@@ -0,0 +1,142 @@
--!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.
Later you replace this with a proper modeled map; the rest of the game
(spawns, races) reads named parts so it keeps working.
]]
local Workspace = game:GetService("Workspace")
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 function part(props: { [string]: any }): Part
local p = Instance.new("Part")
p.Anchored = true
p.TopSurface = Enum.SurfaceType.Smooth
p.BottomSurface = Enum.SurfaceType.Smooth
for k, v in pairs(props) do
(p :: any)[k] = v
end
return p
end
function MapBuilder.build(): Folder
local existing = Workspace:FindFirstChild("Map")
if existing then
existing:Destroy()
end
local map = Instance.new("Folder")
map.Name = "Map"
local roadFolder = Instance.new("Folder")
roadFolder.Name = "Expressway"
roadFolder.Parent = map
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
-- Spawn pad (where cars appear) at the start ------------------------------
local pad = part({
Name = "SpawnPad",
Size = Vector3.new(ROAD_WIDTH - 6, 1, 40),
Position = Vector3.new(0, 1.5, -20),
Color = Color3.fromRGB(20, 220, 120),
Material = Enum.Material.Neon,
Transparency = 0.4,
})
pad.Parent = map
-- Finish line at the far end ----------------------------------------------
local finish = part({
Name = "FinishLine",
Size = Vector3.new(ROAD_WIDTH, 1, 6),
Position = Vector3.new(0, 1.5, -totalLength + 30),
Color = Color3.fromRGB(240, 240, 240),
Material = Enum.Material.Neon,
Transparency = 0.3,
})
finish.Parent = map
-- A floor far below to catch anything that clips through (safety net) ------
local floor = part({
Name = "SafetyFloor",
Size = Vector3.new(ROAD_WIDTH * 4, 2, totalLength + 400),
Position = Vector3.new(0, -60, -totalLength / 2),
Transparency = 1,
CanCollide = true,
})
floor.Parent = map
map.Parent = Workspace
return map
end
return MapBuilder
+107
View File
@@ -0,0 +1,107 @@
--!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
+116
View File
@@ -0,0 +1,116 @@
--!strict
--[[
init.server.luau (SERVER ENTRY POINT)
The one script Roblox runs on the server. It:
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
Everything downstream is server-authoritative: clients REQUEST, server DECIDES.
]]
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Shared = ReplicatedStorage.Shared
local Remotes = require(Shared.Remotes)
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)
-- 1. Remotes ----------------------------------------------------------------
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)
-- 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 -------------
Players.PlayerAdded:Connect(function(player)
DataManager.onReady(player, function(data)
Economy.pushUpdate(player, data)
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)
end)
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)
end)
rEquipCar.OnServerInvoke = function(player, carId)
if typeof(carId) ~= "string" then
return false
end
local data = DataManager.get(player)
if not data or not Economy.ownsCar(data, carId) then
return false
end
data.EquippedCarId = carId
Economy.pushUpdate(player, data)
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.")