PlayDeck
Home / Course / Capstone: Build a Complete Resource
Track A · Module 12

Capstone: Build a Complete Resource

You've learned the pieces — a framework, oxmysql, the ox stack, NUI, and lib.callback. A real resource is what happens when those pieces have to cooperate without leaking money or dropping frames. This capstone is the wiring diagram: how the layers talk, where the security line sits, and why a finished script can idle at 0.00 ms.

What we're building

A scrapyard sell point. A player walks up to a dealer ped, an interaction appears, a small menu lists scrap items and prices, they pick a quantity and confirm — cash lands in their pocket while the items leave their bag. Every transaction is logged to a database.

It's a tiny feature, but it touches every layer you'll use in almost any script: a framework for identity and money, oxmysql for persistence, ox_inventory for items, ox_target for the interaction, ox_lib for the secure round-trip, and an NUI for the menu. Get this shape right and you can build a job, a shop, or a heist on the same skeleton.

The build plan, layer by layer

Decide the layers before you write a line:

Pin ONE framework now and never mix. On Qbox (qbx_core), every player and money call uses Qbox exports. Don't paste an ESX xPlayer.addMoney line into a Qbox script because a tutorial used it — that's the single most common way a build silently breaks.

The security spine: the client is the attacker

The whole feature lives or dies on one rule: the server trusts nothing the client sends. Item, count, and price are all re-derived from server-side truth. Link to the official ox_inventory and qbx_core export docs and confirm each name before you rely on it.

-- server/main.lua — the only file allowed to move money or items.
-- One framework, pinned: Qbox (qbx_core). No ESX/QBCore calls mixed in.
local Config = require 'shared.config'

lib.callback.register('scrapyard:sell', function(source, itemName, requestedCount)
    local listing = Config.Buyable[itemName]
    if not listing then return false, 'not_buyable' end -- unknown item: reject

    -- Sanitise the count: no zero, negatives, decimals, or absurd values.
    local count = tonumber(requestedCount)
    if not count or count < 1 or count ~= math.floor(count) then
        return false, 'bad_count'
    end

    -- ox_inventory is the source of truth for what the player actually holds.
    local owned = exports.ox_inventory:Search(source, 'count', itemName)
    if owned < count then return false, 'not_enough' end

    -- Price comes from server config, NEVER from the client payload.
    local payout = count * listing.price

    -- Effects last: remove items first; only pay if removal succeeded.
    if not exports.ox_inventory:RemoveItem(source, itemName, count) then
        return false, 'remove_failed'
    end

    local player = exports.qbx_core:GetPlayer(source)
    player.Functions.AddMoney('cash', payout, 'scrapyard-sale')

    -- Parameterised query (?) — never concatenate user values into SQL.
    MySQL.insert.await(
        'INSERT INTO scrapyard_log (citizenid, item, qty, payout) VALUES (?, ?, ?, ?)',
        { player.PlayerData.citizenid, itemName, count, payout }
    )

    return true, payout
end)

Read the ordering: validate, check ownership, then remove, then pay, then log. If you pay before removing and the removal fails, you've just minted free money.

Client and UI without an idle loop

The client holds no power — it opens the menu and reports the choice. And it does that without a while true loop, which is how the resource idles at 0.00 ms until someone actually interacts.

-- client/main.lua — requests only. ox_target wakes up near the ped,
-- so there's no per-frame work and no idle cost.
exports.ox_target:addModel(`s_m_y_dealer_01`, {
    {
        name = 'scrapyard:open',
        icon = 'fa-solid fa-recycle',
        label = 'Sell scrap',
        onSelect = function()
            SetNuiFocus(true, true)
            SendNUIMessage({ action = 'open', items = Config.PublicMenu })
        end
    }
})

RegisterNUICallback('sell', function(data, cb)
    -- The server's answer is authoritative — the UI just reflects it.
    local ok, result = lib.callback.await('scrapyard:sell', false, data.item, data.count)
    lib.notify(ok and { type = 'success', description = ('Sold for $%d'):format(result) }
                  or  { type = 'error', description = 'Sale rejected' })
    cb({ ok = ok })
end)

Open the F8 console and run resmon. An event-driven resource like this should read 0.00 ms at rest and only blip during a sale — that's your 0–4 ms target, met by design rather than by optimisation tricks.

Letting AI help without letting it lie

AI is great at scaffolding the NUI and the manifest, and useless at remembering which export is real. It will confidently invent natives, blend ESX and Qbox in the same file, and reach for deprecated TriggerCallback patterns instead of lib.callback. Three habits keep it honest: pin one framework in your prompt and reject any answer that strays; verify every export and native against the official reference, not the model's memory; and paste the pure logic into the PlayDeck sandbox to prove it behaves before it ever touches your server.

Practice

You can test the security spine with zero FiveM. In the PlayDeck browser sandbox, run this stripped-down quote function and feed it cheat inputs:

local function quote(owned, requested, price)
    local n = tonumber(requested)
    if not n or n < 1 or n ~= math.floor(n) then return nil end -- bad count
    if n > owned then return nil end                            -- can't sell what you lack
    return n * price
end

print(quote(10, 3, 50))    -- 150  (valid)
print(quote(10, -3, 50))   -- nil  (negative rejected)
print(quote(10, 2.5, 50))  -- nil  (decimal rejected)
print(quote(10, 999, 50))  -- nil  (more than owned rejected)

Watch every cheat input return nil. That nil is the exploit dying on the server instead of paying out. Now change the rules — add a per-sale cap — and prove your new rule rejects the values it should.

Recap

A complete resource isn't new knowledge — it's the same pieces wired with discipline. Pin one framework. Keep prices and item moves on the server, where the client can't touch them. Order effects so a failure can never mint money. Drive interaction with ox_target and lib.callback so the script costs nothing at rest. And treat AI as a fast typist, not a source of truth — verify against the real docs and the sandbox. That skeleton scales straight into the next job, shop, or heist you build.

Learn it by building it

PlayDeck is an original course on building GTA roleplay scripts with AI — Lua, frameworks, NUI, debugging, and a browser sandbox to test every lesson without booting the game.

Browse the course