PlayDeck
Home / Course / Events & Callbacks
Track A · Module 4

Events & Callbacks

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:

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

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