02. Project: Shop
Goal
Build a complete, secure shop. Pulls together everything you’ve learned: events, framework money, ox_inventory, ox_target, security checklist, rate limiting.
Scope: one ped at Legion Square, target-based menu, 3 items, cash only.
If you only build one project from this course, build this one - it’s a real microcosm of how 80% of FiveM resources work.
Folder
resources/[test]/simple_shop/
├── fxmanifest.lua
├── client/main.lua
└── server/main.lua
In server.cfg:
ensure simple_shop
fxmanifest.lua
fx_version 'cerulean'
game 'gta5'
lua54 'yes'
shared_script '@ox_lib/init.lua'
client_script 'client/main.lua'
server_script 'server/main.lua'
dependency 'ox_target' -- target system
dependency 'ox_inventory' -- item add
dependency 'qbx_core' -- player object + money
Server-Side Config (Authoritative)
In server/main.lua, at the top:
-- ↓ this lives ONLY on the server. clients don't get to see prices.
local CONFIG = {
LOCATION = vec3(25.7, -1347.3, 29.49), -- where the shop is
ITEMS = {
bread = { label = 'Bread', price = 10 },
water = { label = 'Water', price = 5 },
burger = { label = 'Burger', price = 20 },
},
}
Key idea: the client sends an item ID ('bread'). The server looks up the price from this config. Client never sends prices.
client/main.lua
-- ↓ shopkeeper ped definition
local PED_MODEL = `mp_m_shopkeep_01` -- backtick = compile-time hash
local PED_COORDS = vec3(25.7, -1347.3, 28.49) -- ground level (z slightly lower than menu zone)
local PED_HEADING = 266.0 -- direction the ped faces
local SHOP_COORDS = vec3(25.7, -1347.3, 29.49) -- the "shop center" used by server distance check
local shopPed -- handle to the spawned ped (track for cleanup)
-- ↓ spawn the shopkeeper at server start
local function spawnShopkeeper()
RequestModel(PED_MODEL) -- ask the engine to load the ped model
while not HasModelLoaded(PED_MODEL) do Wait(10) end -- wait until loaded
-- ↓ create the ped
-- args: pedType, model, x, y, z, heading, isNetwork, thisScriptCheck
shopPed = CreatePed(4, PED_MODEL, PED_COORDS.x, PED_COORDS.y, PED_COORDS.z, PED_HEADING, false, true)
FreezeEntityPosition(shopPed, true) -- ped doesn't drift
SetEntityInvincible(shopPed, true) -- can't be killed
SetBlockingOfNonTemporaryEvents(shopPed, true) -- ped doesn't react to combat (won't run from gunshots)
SetModelAsNoLongerNeeded(PED_MODEL) -- release the streaming slot
-- ↓ attach an ox_target option to this exact ped
exports.ox_target:addLocalEntity(shopPed, {
{
name = 'simple_shop_open', -- unique ID for cleanup later
icon = 'fa-solid fa-shop',
label = 'Browse Shop',
distance = 2.0, -- max range to interact
onSelect = function()
openShopMenu() -- defined below
end,
},
})
end
-- ↓ open the menu when the player picks "Browse Shop"
function openShopMenu()
-- ask the server for the item list (server is the source of truth for prices)
local items = lib.callback.await('simple_shop:getItems', false)
if not items then return end -- callback failed, bail
-- ↓ build the ox_lib context menu options dynamically from the server's data
local options = {}
for itemId, data in pairs(items) do
options[#options + 1] = {
title = ('%s - $%d'):format(data.label, data.price),
icon = 'fa-solid fa-cart-plus',
onSelect = function()
TriggerServerEvent('simple_shop:buy', itemId) -- server validates and processes
end,
}
end
-- ↓ register the menu and show it
lib.registerContext({
id = 'simple_shop_menu',
title = 'Shop',
options = options,
})
lib.showContext('simple_shop_menu')
end
-- ↓ spawn the ped on resource start (small delay so the game is ready)
CreateThread(function()
Wait(2000)
spawnShopkeeper()
end)
-- ↓ CLEANUP - delete the ped + remove the target on resource stop
AddEventHandler('onResourceStop', function(r)
if r ~= GetCurrentResourceName() then return end
if shopPed and DoesEntityExist(shopPed) then
exports.ox_target:removeLocalEntity(shopPed, 'simple_shop_open')
DeleteEntity(shopPed)
end
end)
Notes on the client side:
- We spawn the ped, set it invincible/frozen so it stays put
- ox_target attaches the menu trigger
- The menu fetches items from the server (no prices in client code)
- Cleanup deletes the ped and removes the target on resource restart
server/main.lua
local CONFIG = { -- already shown above
LOCATION = vec3(25.7, -1347.3, 29.49),
ITEMS = {
bread = { label = 'Bread', price = 10 },
water = { label = 'Water', price = 5 },
burger = { label = 'Burger', price = 20 },
},
}
local cooldowns = {} -- per-player rate limit
local busy = {} -- per-player lock for critical sections
-- ↓ callback that gives the client a safe view of items (label + price for display)
lib.callback.register('simple_shop:getItems', function(src)
local out = {}
for id, data in pairs(CONFIG.ITEMS) do
out[id] = { label = data.label, price = data.price }
end
return out
end)
-- ↓ buy event - runs the full security checklist
RegisterNetEvent('simple_shop:buy', function(itemId)
local src = source -- 1. cache source FIRST
if not src or src <= 0 then return end -- valid src
-- ↓ 2. RATE LIMIT
local now = GetGameTimer()
if cooldowns[src] and (now - cooldowns[src]) < 500 then return end
cooldowns[src] = now
-- ↓ 3. LOCK (prevents parallel-fire dupes)
if busy[src] then return end
busy[src] = true
-- ↓ helper to release the lock on every code path
local function unlock() busy[src] = nil end
-- ↓ 4. VALIDATE TYPES + LENGTH
if type(itemId) ~= 'string' or #itemId > 32 then return unlock() end
-- ↓ 5. WHITELIST (server config has the truth)
local item = CONFIG.ITEMS[itemId]
if not item then return unlock() end
-- ↓ 6. PLAYER LOADED
local player = exports.qbx_core:GetPlayer(src)
if not player then return unlock() end
-- ↓ 7. DISTANCE CHECK (server reads synced position)
local ped = GetPlayerPed(src)
local pos = GetEntityCoords(ped)
if #(pos - CONFIG.LOCATION) > 5.0 then return unlock() end
-- ↓ 8. CAN CARRY? (avoid "took money but inventory was full" rage)
if not exports.ox_inventory:CanCarryItem(src, itemId, 1) then
TriggerClientEvent('ox_lib:notify', src, {
id = 'shop_full',
title = 'Shop',
description = 'Inventory full',
type = 'error',
icon = 'box',
iconColor = '#e63946',
})
return unlock()
end
-- ↓ 9. ATOMIC MONEY (Qbox RemoveMoney returns false if not enough - atomic internally)
if not player.Functions.RemoveMoney('cash', item.price, 'simple_shop:' .. itemId) then
TriggerClientEvent('ox_lib:notify', src, {
id = 'shop_broke',
title = 'Shop',
description = 'Not enough cash',
type = 'error',
icon = 'dollar-sign',
iconColor = '#e63946',
})
return unlock()
end
-- ↓ 10. ADD ITEM
exports.ox_inventory:AddItem(src, itemId, 1)
-- ↓ 11. LOG (every money change goes in the DB)
local cid = player.PlayerData.citizenid
MySQL.insert('INSERT INTO money_log (citizenid, delta, reason) VALUES (?, ?, ?)',
{ cid, -item.price, 'simple_shop_' .. itemId })
-- ↓ 12. NOTIFY THE BUYER
TriggerClientEvent('ox_lib:notify', src, {
id = 'shop_buy',
title = 'Shop',
description = ('Bought %s for $%d'):format(item.label, item.price),
type = 'success',
icon = 'cart-shopping',
iconColor = '#2a9d8f',
duration = 4000,
})
unlock()
end)
-- ↓ cleanup per-player state on disconnect
AddEventHandler('playerDropped', function()
local src = source
cooldowns[src] = nil
busy[src] = nil
end)
DB Setup (Once)
In your MySQL client (HeidiSQL/DBeaver), run:
CREATE TABLE IF NOT EXISTS money_log (
id INT AUTO_INCREMENT PRIMARY KEY,
citizenid VARCHAR(50) NOT NULL, -- which character
delta INT NOT NULL, -- positive (gain) or negative (loss)
reason VARCHAR(100), -- 'simple_shop_bread', etc.
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- when it happened
INDEX idx_cid (citizenid) -- speeds up "show this player's history"
);
Test Flow
- Restart the server (or
ensure simple_shop) - Walk to
vec3(25.7, -1347.3, 29.49)- Legion Square 24/7 shop area - Hold the target key (LEFT ALT by default) → see “Browse Shop” option
- Click it → context menu shows 3 items
- Click bread → “Bought Bread for $10” notification
- Open inventory: bread is there. Cash is reduced by $10.
- Check the DB:
SELECT * FROM money_log ORDER BY id DESC LIMIT 5
Security Audit (Self-Check)
Walk through 08-security/01-security-checklist.md:
- Validate types (
type(itemId) == 'string', length cap) - Whitelist (config table lookup)
- Price from server config, not client args
- Atomic money (
RemoveMoneyreturns bool) - Distance check (5m radius)
- Rate limit (500ms cooldown)
- Lock (busy table)
- Log money change (money_log insert)
-
onResourceStopcleanup (delete ped, remove target)
Passes the audit.
Stretch Goals
If you want to keep building:
- Bank payment option - add a menu choice for “pay with bank” using
RemoveMoney('bank', ...). - Quantity selector - use
lib.inputDialogto ask how many. - Police job discount - server checks
player.PlayerData.job.name == 'police'and applies 10% off. - Multiple shop locations - config array of vec3s, spawn a ped at each, distance check matches the closest.
- Stock system - limited quantity per item per 10-minute window. Track in a Lua table or DB.
Each one is a small lesson in itself. Pick one and try.
TL;DR
- Ped + ox_target = the entry point
- Client asks server for item data via callback (server is the price authority)
- Server validates: types, whitelist, distance, rate limit, lock, atomic money
- ox_inventory adds the item, MySQL logs it
- Cleanup ped + target on resource stop
This is the template for almost any “interaction → money → reward” feature.
Sources
Next: 03-nui-menu.md