Every other module in this track changes one machine. This one is about the gap between two machines — your server and each player's game client — and the messages that cross it. Get the boundary right and your scripts feel instant and stay un-exploitable; get it wrong and the first person with a mod menu rewrites your economy. This is the most security-critical lesson in Track A, so we go slow on the "why."
Two programs, one boundary
A FiveM resource isn't one program. The server runs one Lua state; every connected player's game runs its own client Lua state. They don't share variables and can't call each other's functions. The only way they talk is by sending named messages — events — across the network.
That boundary is the entire reason this module exists. The client sits next to the player: it knows what they're looking at and where they're standing. But the server is the only thing you actually control. So a useful mental model from day one: anything the client tells the server is a suggestion, not a fact.
RegisterNetEvent: opening a door
RegisterNetEvent('name', handler) declares that a named event is allowed to be triggered from the network. Don't register it, and a message from the other side is silently dropped. Register it, and the handler runs. Pass the handler as the second argument — the modern, tidy form — instead of registering and then attaching a separate AddEventHandler.
To send across the boundary:
- From a client:
TriggerServerEvent('name', ...)runs the handler on the server. - From the server:
TriggerClientEvent('name', targetId, ...)runs it on one client (or-1for everyone).
One detail that trips up beginners: on the server, the handler does not receive the player id as a normal argument. FiveM sets a source value for you, and it's trustworthy because the server stamps it — unlike anything inside the payload.
-- server
RegisterNetEvent('playdeck_demo:wave', function()
-- `source` is the player's server id, set by the server. Trust it.
print(('player %s waved'):format(source))
end)
-- client
TriggerServerEvent('playdeck_demo:wave')
Asking for an answer: lib.callback
Events are fire-and-forget. Often you need a reply — "do I own this vehicle?", "how much is in the till?". The old pattern bounced two events back and forth and got messy fast. The modern ox_lib pattern is a callback: one side registers it, the other awaits it.
-- server
lib.callback.register('playdeck_demo:ping', function(source, text)
return ('server heard: %s'):format(text)
end)
-- client
local reply = lib.callback.await('playdeck_demo:ping', false, 'hello')
await pauses just that coroutine until the server returns, then hands you the value. It reads like a normal function call, but it crossed the network. The second argument (false here) is ox_lib's latency/timeout knob — check the current ox_lib docs for its exact behavior rather than guessing.
Never trust the client
Here's the rule that matters more than any syntax: a player's client can send any event, with any payload, at any time. A mod menu can call TriggerServerEvent('playdeck_shop:buy', 'phone', -9999) directly — no UI, no shop, no rules. So validation lives on the server, every time. The client proposes; the server decides.
-- server/shop.lua (Qbox-style — pin ONE framework for the whole resource)
-- The price list lives on the SERVER. If the client could send the price,
-- anyone could "buy" a car for $0. Authority stays here.
local PRICES = { water = 5, bread = 8, phone = 250 }
lib.callback.register('playdeck_shop:buy', function(source, itemName, amount)
-- 1) Validate the request BEFORE touching anything.
local price = PRICES[itemName]
if not price then return false, 'unknown item' end
-- A modded client can send -50 or 1e9. Coerce and clamp.
amount = tonumber(amount)
if not amount or amount < 1 or amount > 10 then
return false, 'bad amount'
end
local total = price * amount
-- 2) Re-check authority on the server. Never ask the client "can you afford it?"
-- Note: exports/methods vary by framework + version — verify against Qbox docs.
local player = exports.qbx_core:GetPlayer(source)
if not player then return false, 'no player' end
if player.PlayerData.money.cash < total then
return false, 'not enough cash'
end
-- 3) Effects last (checks -> effects). Money out, item in.
if not player.Functions.RemoveMoney('cash', total, 'shop-purchase') then
return false, 'charge failed'
end
local added = exports.ox_inventory:AddItem(source, itemName, amount)
if not added then
-- Inventory rejected it (full?). Refund so the economy stays honest.
player.Functions.AddMoney('cash', total, 'shop-refund')
return false, 'inventory full'
end
return true, total
end)
Notice the order: checks first, then effects, with a refund if the last step fails. That's the same Checks-Effects discipline you'd use anywhere money moves — and it's why the server, not the client, owns the price list.
Where AI helps, and where it bites
AI is great for scaffolding this boilerplate, but it fails here in predictable ways. It invents natives and exports that don't exist (exports.shop:buyItem), mixes ESX, QBCore, and Qbox calls in one file, reaches for deprecated callback libraries, and — worst — happily writes "give the player whatever the client asked for" with no server check.
Three habits fix all of it. Pin ONE framework for the resource and reject suggestions from the others. Verify every native and export against the official reference (the FiveM native docs, the ox_lib docs, your framework's docs — link them, don't trust memory). And run the logic in the PlayDeck sandbox before it touches a live server.
Practice
No FiveM needed — open the PlayDeck browser sandbox and model the trust boundary with plain Lua. Paste this and run it:
local PRICES = { water = 5, phone = 250 }
local function validatePurchase(itemName, amount, cash)
local price = PRICES[itemName]
if not price then return false, 'unknown item' end
amount = tonumber(amount)
if not amount or amount < 1 or amount > 10 then return false, 'bad amount' end
local total = price * amount
if cash < total then return false, 'not enough cash' end
return true, total
end
-- Four "client payloads" — three hostile, one honest.
print(validatePurchase('phone', -9999, 1000)) -- expect: false bad amount
print(validatePurchase('gold', 1, 1000)) -- expect: false unknown item
print(validatePurchase('phone', 2, 100)) -- expect: false not enough cash
print(validatePurchase('water', 3, 1000)) -- expect: true 15
Then change one rule — say, allow up to 20 of an item — and confirm only the payloads you intended now pass. You just wrote server-side validation without a server.
Recap
- Client and server are separate Lua states; they talk only through named events across the network.
RegisterNetEventpermits an event;TriggerServerEvent/TriggerClientEventsend across the boundary;sourceon the server is the one client-supplied value you can trust.- Use
lib.callback.register/lib.callback.awaitwhen you need a reply — it reads like a function call but crosses the network. - The client proposes, the server disposes: validate every payload server-side, check before effect, refund on failure.
- Make AI verify, not invent — one framework, real references, and a sandbox run before anything goes live.