Guide 2026-05-01

FiveM Car Dealer & Vehicle Shop Development

OntelMonke

OntelMonke

Admin & Developer at Agency Scripts

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.

Share this article

Ready to upgrade your server?

Check out our premium FiveM scripts in the Agency Scripts store or join our Discord community for support and updates.