Vehicle Dealership Architecture
A car dealer system is one of the primary economic drivers on any FiveM roleplay server, serving as the gateway through which players acquire their vehicles. A well-designed dealership goes beyond a simple purchase menu, offering features like vehicle previews in a showroom, test drives, financing options, trade-ins, and sales tracking for dealer employees. The architecture divides into a catalog system that defines available vehicles with pricing and categories, a showroom display that lets players inspect vehicles before buying, a transaction engine that handles purchases and financing, and an employee management layer for player-run dealerships. Each layer communicates through server-validated events to prevent price manipulation and unauthorized vehicle spawning.
Vehicle Catalog and Category System
The vehicle catalog defines every car available for purchase, organized into categories for easy browsing. Each entry includes the spawn name, display label, price, category, and optional metadata like top speed and seat count for the showroom display. Store the catalog in a shared configuration file that both client and server reference, ensuring price validation happens server-side while the client uses the same data for rendering. Here is a structured catalog definition:
Config.VehicleCatalog = {
categories = {
{id = 'sedan', label = 'Sedans', icon = 'fa-car'},
{id = 'sport', label = 'Sports Cars', icon = 'fa-flag-checkered'},
{id = 'suv', label = 'SUVs & Trucks', icon = 'fa-truck'},
{id = 'muscle', label = 'Muscle Cars', icon = 'fa-bolt'},
{id = 'motorcycle', label = 'Motorcycles', icon = 'fa-motorcycle'},
},
vehicles = {
-- Sedans
{model = 'sultan', label = 'Karin Sultan', price = 24500,
category = 'sedan', seats = 4, testDrive = true},
{model = 'schafter2', label = 'Benefactor Schafter', price = 38000,
category = 'sedan', seats = 4, testDrive = true},
-- Sports
{model = 'elegy2', label = 'Annis Elegy RH8', price = 95000,
category = 'sport', seats = 2, testDrive = true},
{model = 'comet2', label = 'Pfister Comet', price = 110000,
category = 'sport', seats = 2, testDrive = true},
-- SUVs
{model = 'baller', label = 'Gallivanter Baller', price = 55000,
category = 'suv', seats = 4, testDrive = true},
-- Muscle
{model = 'dominator', label = 'Vapid Dominator', price = 42000,
category = 'muscle', seats = 2, testDrive = true},
-- Motorcycles
{model = 'bati', label = 'Pegassi Bati 801', price = 18000,
category = 'motorcycle', seats = 2, testDrive = true},
},
}
-- Build lookup table for fast server-side price validation
Config.VehiclePrices = {}
for _, v in ipairs(Config.VehicleCatalog.vehicles) do
Config.VehiclePrices[v.model] = v.price
end
The price lookup table Config.VehiclePrices enables O(1) price validation on the server, preventing clients from sending manipulated prices. Always validate the model name against this table before processing a purchase. Consider loading vehicle prices from a database table instead of a config file if you want server administrators to adjust prices through an admin panel without restarting the server.
Showroom Preview System
The showroom lets players inspect vehicles in a controlled environment before committing to a purchase. Spawn a preview vehicle at a designated showroom position, apply a camera that the player can rotate around the vehicle, and display stats alongside the model. The preview vehicle should be non-interactable and despawned when the player closes the menu or selects a different vehicle. Here is the client-side preview logic:
-- client/showroom.lua
local previewVehicle = nil
local previewCam = nil
local camAngle = 0.0
function ShowVehiclePreview(modelName)
-- Clean up previous preview
DestroyPreview()
local model = GetHashKey(modelName)
RequestModel(model)
while not HasModelLoaded(model) do Wait(10) end
local showroomPos = Config.ShowroomPosition -- vector4
previewVehicle = CreateVehicle(model, showroomPos.x, showroomPos.y,
showroomPos.z, showroomPos.w, false, false)
SetEntityInvincible(previewVehicle, true)
SetVehicleDoorsLocked(previewVehicle, 2)
FreezeEntityPosition(previewVehicle, true)
SetVehicleOnGroundProperly(previewVehicle)
SetModelAsNoLongerNeeded(model)
-- Create orbiting camera
previewCam = CreateCam('DEFAULT_SCRIPTED_CAMERA', true)
UpdateCameraPosition()
SetCamActive(previewCam, true)
RenderScriptCams(true, true, 500, true, true)
end
function UpdateCameraPosition()
if not previewCam or not previewVehicle then return end
local vehPos = GetEntityCoords(previewVehicle)
local radius = 6.0
local height = 2.0
local rad = math.rad(camAngle)
local camX = vehPos.x + radius * math.cos(rad)
local camY = vehPos.y + radius * math.sin(rad)
SetCamCoord(previewCam, camX, camY, vehPos.z + height)
PointCamAtEntity(previewCam, previewVehicle, 0.0, 0.0, 0.0, true)
end
function DestroyPreview()
if previewVehicle then
DeleteEntity(previewVehicle)
previewVehicle = nil
end
if previewCam then
SetCamActive(previewCam, false)
RenderScriptCams(false, true, 500, true, true)
DestroyCam(previewCam, false)
previewCam = nil
end
end
Allow camera rotation through mouse movement or keyboard controls while the showroom is open. The orbit camera approach lets players view the vehicle from all angles without needing to walk around it. Add vehicle color customization to the preview so players can see their desired paint job before buying, which reduces buyer's remorse and support requests.
Purchase and Transaction Processing
Vehicle purchases must be processed entirely on the server side to prevent exploitation. The server validates that the player can afford the vehicle, deducts the payment, generates a unique license plate, creates the vehicle record in the database, and notifies the client to spawn the purchased vehicle. Support both full cash purchases and bank transfers, and optionally integrate with your banking system for wire transfers:
RegisterNetEvent('dealer:server:purchaseVehicle', function(modelName, paymentType)
local src = source
local Player = QBCore.Functions.GetPlayer(src)
if not Player then return end
-- Validate model exists and get price
local price = Config.VehiclePrices[modelName]
if not price then
TriggerClientEvent('QBCore:Notify', src, 'Vehicle not available', 'error')
return
end
-- Check payment
local moneyType = paymentType == 'bank' and 'bank' or 'cash'
if Player.PlayerData.money[moneyType] < price then
TriggerClientEvent('QBCore:Notify', src, 'Insufficient funds', 'error')
return
end
-- Generate unique plate
local plate = GenerateUniquePlate()
-- Process payment
Player.Functions.RemoveMoney(moneyType, price, 'vehicle-purchase-' .. modelName)
-- Create vehicle record
local vehicleHash = GetHashKey(modelName)
MySQL.insert([[
INSERT INTO player_vehicles
(citizenid, vehicle, hash, plate, garage, state, fuel, engine, body)
VALUES (?, ?, ?, ?, ?, 1, 100, 1000.0, 1000.0)
]], {
Player.PlayerData.citizenid,
modelName,
tostring(vehicleHash),
plate,
'pillboxgarage',
})
-- Log transaction
MySQL.insert([[
INSERT INTO vehicle_sales (citizenid, vehicle, plate, price, sold_at)
VALUES (?, ?, ?, ?, NOW())
]], {Player.PlayerData.citizenid, modelName, plate, price})
TriggerClientEvent('QBCore:Notify', src,
'Vehicle purchased! Plate: ' .. plate, 'success')
TriggerClientEvent('dealer:client:vehiclePurchased', src, modelName, plate)
end)
function GenerateUniquePlate()
local plate
repeat
plate = ''
local chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
for i = 1, 8 do
local idx = math.random(1, #chars)
plate = plate .. chars:sub(idx, idx)
end
local exists = MySQL.scalar.await(
'SELECT 1 FROM player_vehicles WHERE plate = ?', {plate}
)
until not exists
return plate
end
The GenerateUniquePlate function uses a retry loop to guarantee plate uniqueness across the entire database. While collisions are statistically rare with 8 alphanumeric characters, the check ensures absolute safety. Log every sale in a separate table for administrative review and server economy analytics, tracking which vehicles are most popular and the total cash flow through the dealership.
Test Drive System
Test drives let players experience a vehicle before buying, which is especially important for expensive vehicles. Spawn a temporary vehicle with a timer and geographic boundary, automatically returning the player to the dealership when time expires or they leave the allowed area. Mark the test drive vehicle so it cannot be stored in a garage or modified, preventing players from exploiting the system to get free vehicles:
-- Client: test drive logic
local testDriveVehicle = nil
local testDriveTimer = 0
local testDriveActive = false
function StartTestDrive(modelName, duration)
local model = GetHashKey(modelName)
RequestModel(model)
while not HasModelLoaded(model) do Wait(10) end
local spawnPos = Config.TestDriveSpawn
testDriveVehicle = CreateVehicle(model, spawnPos.x, spawnPos.y,
spawnPos.z, spawnPos.w, true, false)
SetVehicleNumberPlateText(testDriveVehicle, 'TESTDRVE')
TaskWarpPedIntoVehicle(PlayerPedId(), testDriveVehicle, -1)
SetModelAsNoLongerNeeded(model)
testDriveTimer = duration
testDriveActive = true
-- Timer and boundary check thread
CreateThread(function()
while testDriveActive and testDriveTimer > 0 do
Wait(1000)
testDriveTimer = testDriveTimer - 1
-- Show remaining time
SendNUIMessage({
action = 'updateTestDrive',
timeLeft = testDriveTimer
})
-- Check boundary
local playerPos = GetEntityCoords(PlayerPedId())
local dealerPos = Config.DealerLocation
if #(playerPos - dealerPos) > Config.TestDriveRadius then
QBCore.Functions.Notify('Too far from dealer, returning...', 'error')
EndTestDrive()
return
end
end
if testDriveActive then
EndTestDrive()
end
end)
end
function EndTestDrive()
testDriveActive = false
if testDriveVehicle and DoesEntityExist(testDriveVehicle) then
DeleteEntity(testDriveVehicle)
testDriveVehicle = nil
end
local returnPos = Config.DealerLocation
SetEntityCoords(PlayerPedId(), returnPos.x, returnPos.y, returnPos.z)
QBCore.Functions.Notify('Test drive ended', 'info')
end
The geographic boundary prevents players from driving the test vehicle across the map and abandoning it. Set the radius to a reasonable distance that allows a meaningful drive through nearby streets while keeping the vehicle recoverable. The "TESTDRVE" plate serves as a visual indicator to other players that the vehicle is temporary.
Financing and Payment Plans
For expensive vehicles, offer a financing option where players make a down payment and then pay installments over time. Track the loan in a database table with the remaining balance, payment schedule, and interest rate. If a player misses payments, the vehicle can be flagged for repossession. This feature adds economic depth and makes high-end vehicles accessible to players who have not accumulated enough cash for a full purchase. Implement the installment check as a server-side recurring job that runs daily in game-time, deducting payments from the player's bank account and notifying them of each charge. If the bank balance is insufficient, increment a missed payment counter and issue a warning. After a configurable number of missed payments, mark the vehicle for repossession where it is removed from the player's garage and returned to dealer inventory.
Employee Sales and Commission Tracking
Player-run dealerships need tools for managing sales staff, tracking performance, and distributing commissions. When a dealer employee facilitates a sale, they earn a configurable percentage of the vehicle price as commission. Track each employee's sales count, total revenue generated, and commission earned in a database table. Create an employee dashboard accessible through the dealership NUI that shows personal statistics, recent sales history, and a leaderboard comparing performance across the sales team. The dealership owner or manager grade should have access to an admin panel for adjusting commission rates, adding or removing employees, and viewing aggregate sales reports. This transforms the dealership from a static NPC interaction into a dynamic player business with real management responsibilities and competitive incentives.
Optimization and Anti-Exploit Measures
Dealership scripts face common exploit vectors that need proactive mitigation. The most critical is price manipulation, where a modified client sends a purchase request with a lower price. Always validate prices server-side against the catalog and never trust client-reported values. Rate-limit purchase events to prevent rapid-fire buying that could duplicate vehicles or create database race conditions. For the showroom preview, ensure the preview vehicle is created with false for the network parameter so it only exists locally and cannot be entered or stolen by other players. Clean up preview vehicles in the onResourceStop handler to prevent orphaned entities if the resource is restarted. For servers with multiple dealerships, cache the vehicle catalog in memory and only reload it when an admin triggers a refresh command, avoiding repeated config file reads on every NUI open. Monitor purchase logs for anomalies like the same player buying dozens of vehicles in rapid succession, which may indicate an exploit or money duplication that warrants investigation.