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
+28 -34
View File
@@ -1,25 +1,25 @@
# Fast & Furious: Tokyo Expressway — MVP
# Fast & Furious: Tokyo Expressway — MVP (drive-only)
A Roblox street-racing simulator. Start in a 1993 Mazda RX-7 (the owner's real car),
race the neon Tokyo expressway, earn cash + rep, buy and upgrade faster cars.
A Roblox game where you cruise a neon Tokyo expressway at night in cool cars —
starting with a real 1993 Mazda RX-7. **This MVP is deliberately tiny: just
driving.** No racing, money, or upgrades yet — the goal is to nail the driving
feel and be testable fast. See `docs/ROADMAP.md` for what comes next.
**Status:** Playable vertical slice. Drive → race → earn → save works end to end.
Built code-first — there is nothing to hand-build in Studio to run it.
**Status:** Playable. Spawn → drive → switch cars → respawn. CI green on a
self-hosted Gitea runner. Built code-first — nothing to hand-build in Studio.
---
## Prerequisites
Toolchain is managed by [Rokit](https://github.com/rojo-rbx/rokit). Once Rokit is
installed and on your `PATH`:
Toolchain is managed by [Rokit](https://github.com/rojo-rbx/rokit). With Rokit on
your `PATH`:
```bash
rokit install # installs rojo, wally, selene, stylua at pinned versions
rokit install # rojo, wally, selene, stylua at pinned versions
wally install # restores ProfileStore into ServerPackages/
```
Installed tools (see `rokit.toml`):
| Tool | Purpose |
|---|---|
| rojo | Sync/build code ↔ Roblox place |
@@ -30,17 +30,16 @@ Installed tools (see `rokit.toml`):
## Build & run
```bash
# Build a standalone place file you can open in Studio:
rojo build --output build.rbxl
rojo build --output build.rbxl # standalone place file to open in Studio
# OR live-sync into an open Studio place (install the Rojo Studio plugin):
rojo serve
```
Then in Studio press **Play**. You spawn on the expressway; use the bottom bar:
**GARAGE** (buy/equip/upgrade), **SPAWN CAR**, **RACE**, **DAILY**.
In Studio press **Play**. You spawn on the expressway in your car. Bottom bar:
**CARS** (switch car) and **RESPAWN** (re-spawn if you flip). The RX-7 only
appears for allowlisted accounts.
## Quality gates (run before every commit)
## Quality gates (CI enforces these on every push)
```bash
stylua --check src/ # formatting
@@ -48,41 +47,36 @@ selene src/ # lint
rojo build --output build.rbxl # must compile
```
CI runs all three on every push (see `.github/` → mirrored to Gitea Actions).
CI runs on a self-hosted **Gitea Actions** runner on UNRAID-1 (Ubuntu 24.04 job
container — rojo's binary needs glibc 2.39). Workflow: `.gitea/workflows/ci.yml`.
---
## Architecture (server-authoritative)
Clients **request**, the server **decides**. Money, rep, and car ownership can
never be set by the client.
Clients **request**, the server **decides**. The RX-7 allowlist and anti-cheat
are server-side.
```
ReplicatedStorage/Shared GameConfig, Cars, ProfileTemplate, Remotes
ServerScriptService/Server init.server + managers (below)
ServerScriptService/ServerPackages ProfileStore (Wally)
StarterPlayerScripts/Client init.client (HUD + Garage UI)
StarterPlayerScripts/Client init.client (HUD + car picker)
```
| Module | Responsibility |
|---|---|
| `DataManager` | Wraps **ProfileStore** — session locking, reconcile, Studio mock. The data-safety foundation. |
| `Economy` | The only place cash/rep/ownership change. Buy, upgrade, daily, race payouts. |
| `CarManager` | Spawns the equipped car (VehicleSeat-driven placeholder) with upgrade-scaled tuning. |
| `RaceManager` | Solo time-trial; server validates the finish-line touch before paying out. |
| `MapBuilder` | Procedurally builds the expressway, spawn pad, finish line, neon pylons, kill walls. |
| `DataManager` | Wraps **ProfileStore** — session locking, reconcile, Studio mock. Remembers your last car. |
| `CarManager` | Spawns the equipped car (VehicleSeat-driven placeholder) and enforces the RX-7 allowlist. |
| `MapBuilder` | Procedurally builds the expressway, spawn pad, neon pylons, kill walls. |
| `AntiCheat` | Movement sanity check (speed/teleport) — a speed bump, not a fortress. |
See `docs/GDD.md` for the game design doc and `docs/ROADMAP.md` for the realistic
solo-dev plan.
The RX-7 demo-lock: `GameConfig.RX7AllowedUserIds` holds the owner's + son's
Roblox UserIds; only they can select/drive the RX-7 (enforced in `CarManager` and
the `EquipCar` remote).
## Replacing the placeholder RX-7 with the real mesh
The car is currently a coloured block + `VehicleSeat` so the game is drivable today.
When you import the real RX-7 model:
1. Drop the mesh/model into `ReplicatedStorage` (or an assets folder).
2. Rewrite only `CarManager.buildCarModel` to clone it and weld a `VehicleSeat`.
3. Everything else (economy, upgrades, races, saving) keeps working unchanged.
Reference photos of the real car live on the NAS at
One function changes: `CarManager.buildCarModel`. Full step-by-step in
`docs/MESH_SWAP.md`. Reference photos live on the NAS at
`unraid-1:/mnt/user/data/yy-roblox-games/0-fast-n-furious-sim/001-1993 Mazda RX-7/`.
+29 -43
View File
@@ -1,52 +1,38 @@
# Game Design Document — Fast & Furious: Tokyo Expressway (MVP)
## Vision
A progression-fantasy street racer. You start in a humble-but-iconic 1993 Mazda
RX-7 and claw your way up to hypercars on a neon-drenched Tokyo expressway at night.
Easy to pick up, satisfying to grind, fair to spend on.
A neon Tokyo expressway you can cruise at night in cool cars — starting with a
real 1993 Mazda RX-7. The long-term dream is a full street-racing simulator, but
the **MVP is deliberately tiny: just drive.**
## The one-sentence pitch
"Start in the RX-7, own the expressway."
## MVP scope (what's built) — JUST DRIVING
- Spawn on a neon Tokyo expressway at night.
- Drive a car around the highway (built on Roblox's reliable VehicleSeat).
- Switch between cars via a simple "CARS" picker.
- Respawn your car if you flip or fall off.
- The **RX-7 is demo-locked** to the owner + son (Roblox UserId allowlist,
enforced server-side). Everyone else drives the public cars.
- Your last car is remembered between sessions (ProfileStore).
## Core loop (the addictive 60 seconds)
1. **Spawn** on the expressway in your equipped car.
2. **Race** the time-trial down the highway to the finish line.
3. **Earn** cash + reputation for finishing.
4. **Spend** in the Garage: upgrade your current car or buy the next tier.
5. A faster car / better tune makes the next race feel better → back to step 1.
That's it. No racing, no money, no rep, no upgrades, no daily rewards. The point
of the MVP is to get the car feeling good on the road and be testable fast.
Daily rewards + rising rep gates give a reason to come back tomorrow.
## Why this cut
Driving feel is the hardest, most important part of a car game. Everything else
(racing, economy, progression) is worthless if the car isn't fun to drive. So the
MVP nails driving first; the rest is layered on later once it feels right.
## Pillars
- **Progression fantasy** — junker → hypercar, always a visible next goal.
- **Nighttime Tokyo vibe** — neon, speed, style.
- **Fair monetization** — spending accelerates the grind, never buys wins.
- **Server-authoritative** — no exploiting money or unlocking cars.
## Pillars (for the eventual full game)
- Progression fantasy — junker → hypercar (LATER).
- Nighttime Tokyo vibe — neon, speed, style (NOW: the map).
- Fair monetization (LATER).
- Server-authoritative — no exploiting (NOW: allowlist + anti-cheat foundations).
## MVP feature set (what's built)
- 5-tier car catalogue; **RX-7 is the free starter** (Tier 2).
- Drivable cars via VehicleSeat (proven, swap-in real mesh later).
- Procedural expressway map with kill-walls (can't leave the road).
- Garage + Dealership UI: view / buy / equip / upgrade.
- Upgrade system: Engine / Tires / Nitro, cost-scaled with diminishing returns.
- Solo time-trial race with server-validated payouts.
- Reputation gates on higher-tier cars.
- Daily login reward.
- Robust saving via ProfileStore (session-locked, reconciling).
- Basic movement anti-cheat.
## Deferred (see ROADMAP)
Racing, checkpoints, money/economy, reputation, upgrades, customization, daily
rewards, traffic AI, leaderboards, monetization, crews/PvP, full city map.
## Deliberately deferred (see ROADMAP)
Traffic AI, head-to-head/drag racing, car customization cosmetics, crews/PvP,
full city map, mobile-tuned optimization pass, monetization products.
## Economy first-pass numbers (tune freely in `GameConfig`)
- Start: $1,500, 0 rep, own the RX-7.
- Race finish: +$650 (base+win) and +15 rep.
- Daily: +$300, +10 rep.
- Upgrades: base $500, ×1.6 per level, cap level 5 per part.
- Car tiers: Junk $800 → S15 $12k (50 rep) → R34 $45k (180 rep) → Hyper $250k (600 rep).
## Win condition / retention
No hard "end" — the chase up the tiers plus daily rewards and (future) leaderboards
supply the long tail. First-session goal: earn enough to feel one upgrade land
inside the first 10 minutes.
## Cars in the picker
RX-7 (restricted), City Runabout (public default), Silvia S15, Skyline GT-R R34,
and a stylized hypercar. Stats (Speed/Accel/Handling, 0-100) only affect driving
feel — there are no prices or unlocks in the MVP.
+84
View File
@@ -0,0 +1,84 @@
# Swapping the placeholder RX-7 for the real mesh (M1)
Right now every car is a coloured block + a `VehicleSeat` so the game is drivable
today. This is the **one file** you touch to make the RX-7 a real model:
`src/server/CarManager.luau` → the `buildCarModel` function.
Nothing else (saving, the allowlist, car switching, the map, anti-cheat) needs to
change. That's the whole point of isolating model-building in one function.
## Reference photos
The real car's reference photos live on the NAS (too large to commit to git):
```
unraid-1:/mnt/user/data/yy-roblox-games/0-fast-n-furious-sim/001-1993 Mazda RX-7/
```
A single downscaled reference is included at `assets/reference/rx7-reference.png`
for quick lookups.
## Step 1 — Get a model into Roblox
Options, easiest first:
1. **Buy or find a free RX-7 FD model** on the Roblox Creator Marketplace/toolbox,
or
2. **Model it** in Blender from the reference photos and import the mesh
(`.fbx`/`.obj`) via Studio's 3D importer, or
3. Commission one.
You want a single `Model` whose parts are welded together, with a clear "body"
part you can drive. Keep the part count low (mobile perf).
## Step 2 — Store it where Rojo/Studio can clone it
Put the finished `Model` under `ReplicatedStorage` (e.g. a `CarModels` folder).
Two ways to manage it:
- **Simple:** keep it in the Studio place file as a real Instance (not synced by
Rojo). Fine for a single hero car.
- **Version-controlled:** export the model to a `.rbxm` and reference it from the
Rojo project so it lives in git. See Rojo's `$path` docs for `.rbxm` model files.
## Step 3 — Rewrite `buildCarModel` to clone the real mesh
Replace the block-building body of `buildCarModel(car, maxSpeed, torque, turnSpeed)`
with something like:
```lua
local CAR_MODELS = ReplicatedStorage:WaitForChild("CarModels")
local function buildCarModel(car, maxSpeed, torque, turnSpeed)
-- Look up a real model by car id; fall back to the block builder if missing.
local template = CAR_MODELS:FindFirstChild(car.Id)
if not template then
return buildBlockCar(car, maxSpeed, torque, turnSpeed) -- keep the old code as a fallback
end
local model = template:Clone()
-- Ensure it has a VehicleSeat named "DriveSeat" welded to the driving body.
local seat = model:FindFirstChild("DriveSeat")
if not seat then
seat = Instance.new("VehicleSeat")
seat.Name = "DriveSeat"
-- weld seat to model.PrimaryPart, position it in the cabin...
seat.Parent = model
end
seat.MaxSpeed = maxSpeed
seat.Torque = torque
seat.TurnSpeed = turnSpeed
assert(model.PrimaryPart, "Car model needs a PrimaryPart set")
return model
end
```
Keep the current block-builder renamed as `buildBlockCar` so any car without a
real mesh still works. That way you can import cars one at a time.
## Step 4 (recommended, M1) — Move to a real chassis
The `VehicleSeat` is great for "it drives today" but for real suspension/grip,
adopt a proven **constraint chassis** (A-Chassis or a modern equivalent). At that
point `buildCarModel` clones the chassis rig and attaches the RX-7 body to it.
Still one function; still no changes elsewhere.
## Verify
- `rojo build --output build.rbxl` compiles.
- In Studio: Play → you spawn in the real RX-7 → it drives → it saves.
- Non-allowlisted test account still can't select the RX-7 (server enforced).
+40 -42
View File
@@ -1,62 +1,60 @@
# Roadmap — realistic solo-dev plan
This replaces the original doc's optimistic 12-week timeline. It assumes **one
part-time developer learning as they go**. Ship the vertical slice, then layer
polish. Each milestone is independently playable/testable.
Assumes **one part-time developer learning as they go**. The MVP is intentionally
tiny — *just driving* — so there's a fun, testable thing FAST. Everything else is
layered on only after the driving feels good.
> Rule of thumb: the original guide's "weeks" are really "milestones." Expect
> **4–8 months** of part-time work to a polished, launch-ready MVP. That's normal.
> The goal of this order is that you always have something that *runs*.
> Expect **months, not weeks** to a polished full game. That's normal. This order
> guarantees you always have something that runs.
---
## ✅ M0 — Vertical slice (DONE — this repo)
Drive → race → earn → buy/upgrade → **save**. Server-authoritative. Placeholder
RX-7 block car, procedural expressway. Proves the whole stack end to end.
## ✅ M0 — Drive-only MVP (DONE — this repo)
Spawn on the neon expressway, drive a car, switch cars, respawn. RX-7 demo-locked
to owner + son. Cars persist between sessions. CI green on a self-hosted runner.
## M1 — Make it feel good (the real work)
## M1 — Make driving feel GOOD (the real work, do this next)
- [ ] Import the **real RX-7 mesh**; swap `CarManager.buildCarModel` only.
- [ ] Adopt a proven **constraint chassis** (A-Chassis or modern equivalent) for
real suspension/grip instead of the raw VehicleSeat. Do NOT hand-roll physics.
- [ ] Tune driving feel: acceleration curve, grip, camera.
- [ ] Sound: engine loop, boost, tire screech.
**Exit test:** the RX-7 is genuinely fun to just drive around.
See `docs/MESH_SWAP.md` for the exact steps.
- [ ] Adopt a proven **constraint chassis** (A-Chassis or a modern equivalent)
for real suspension/grip instead of the raw VehicleSeat. Do NOT hand-roll
physics.
- [ ] Tune acceleration curve, grip, and the chase camera.
- [ ] 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 — Race depth
- [ ] Checkpoints + lap/segment timing (extend `RaceManager`).
- [ ] Personal best times saved to profile.
- [ ] A second race type (drag / point-to-point).
- [ ] Countdown + results screen.
## M2 — More cars + polish (still no racing)
- [ ] Import a few more real car meshes.
- [ ] Better map: more expressway sections, ramps, tunnels, scenery.
- [ ] Day/night, weather mood.
## M3 — Content & progression polish
- [ ] Flesh out the 25-car catalogue across tiers.
- [ ] Car customization cosmetics (rims, wraps, neon) — visual only for MVP.
- [ ] Daily missions (not just login): "win 3 races", "earn $5k".
- [ ] Tutorial for the first 10 minutes.
## M3 — Racing (the first "game" layer)
- [ ] Checkpoints + timing (a new RaceManager — the old one was removed in M0).
- [ ] Personal best times.
- [ ] Simple point-to-point and drag modes.
## M4 — Systems hardening
- [ ] Leaderboards (OrderedDataStore: top rep, fastest times).
- [ ] Anti-cheat pass 2 (validate race times against car stats).
- [ ] DataStore stress test / backup-version restore drill.
- [ ] Performance pass: StreamingEnabled tuning, part budget, mobile test.
## M4 — Progression & economy (only once racing is fun)
- [ ] Money from races, a garage, buying/unlocking cars, upgrades, reputation.
(These systems existed as scaffolding and were cut for the MVP; reintroduce
them deliberately when they have a purpose.)
## M5 — Monetization (only after it's fun)
- [ ] 2× Cash gamepass (the classic first product).
- [ ] VIP membership (daily bonus, cosmetic-only exclusives).
- [ ] Nitro/boost dev-products — **never** pay-to-win on ranked races.
- [ ] Receipt handling via ProfileStore `LastSavedData`.
## M5 — Systems hardening & monetization
- [ ] Leaderboards, anti-cheat pass 2, DataStore backup drill.
- [ ] Performance pass (StreamingEnabled, part budget, mobile).
- [ ] Fair gamepasses/products — never pay-to-win.
## M6 — Traffic AI (deferred hardest system)
- [ ] Start with **spline-following** dummy cars on fixed paths (cheap, no lag).
Only attempt true pathfinding AI if performance allows.
- [ ] Spawn/despawn by distance to keep part count sane.
- [ ] Spline-following dummy cars first (cheap). True AI only if perf allows.
---
## Standing engineering rules
- Every change must pass `stylua --check`, `selene`, and `rojo build` (CI enforces).
- Keep the client dumb: it requests, the server decides. No exceptions for money.
- Add new tunables to `GameConfig`, not scattered magic numbers.
- Every change must pass `stylua --check`, `selene`, and `rojo build` — CI on the
self-hosted Gitea runner enforces all three on every push.
- Keep the client dumb: it requests, the server decides. The RX-7 allowlist and
anti-cheat live server-side.
- Add tunables to `GameConfig`, not scattered magic numbers.
- Add a save key by adding it to `ProfileTemplate` (auto-reconciles); never remove
a key without a migration.
a key without a migration (bump `ProfileStoreName` `_vN` in dev to wipe).
- Test in Studio after every server change (ProfileStore uses a mock store there).
+67 -171
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"
if isCurrent then
action.Text = "DRIVING"
action.BackgroundColor3 = Color3.fromRGB(40, 60, 55)
action.TextColor3 = Color3.fromRGB(120, 255, 170)
else
action.Text = "EQUIP"
action.Text = "DRIVE"
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
showToast("Now driving " .. car.Name)
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.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")
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"