Skip to content
Client, server and events

Client, server and events

How FiveM client and server scripts talk: local and net events, TriggerServerEvent, TriggerClientEvent, source, callbacks with ox_lib, and why you never trust the client.

Last updated

Every FiveM script runs on one of two sides, and the sides can only talk through events. Getting this right is the difference between a script that works and a script that gives cheaters free money.

Plays from youtube-nocookie.com after you click.

The two sides

Client Server
Runs On each player’s PC, inside their game Once, inside FXServer
Knows Its own player, what’s near them, input, the screen All players, the database, the truth
Can do Draw, read keys, play animations, local effects, most game natives Kick, ban, save data, send events to anyone, server natives
Trust None. Cheaters control it. Full.

A resource can have client, server and shared scripts. Shared scripts (config, utility functions) run on both sides, but each side has its own copy: changing Config.x on the client does nothing on the server.

Local events

TriggerEvent fires an event on the same side, to every resource that listens:

Lua
-- any resource on the same side
AddEventHandler('myres:somethingHappened', function(what)
    print('It happened: ' .. what)
end)

TriggerEvent('myres:somethingHappened', 'a thing')

Prefix event names with your resource name (myres:...) so they don’t clash with other resources.

Network events

To cross from client to server or back, the receiving side must register the event as a net event.

Client to server

client.lua
RegisterCommand('report', function(_, args)
    local message = table.concat(args, ' ')
    TriggerServerEvent('myres:report', message)
end, false)
server.lua
RegisterNetEvent('myres:report', function(message)
    local src = source                 -- who sent it, copy it right away
    if type(message) ~= 'string' or #message == 0 or #message > 200 then return end
    print(('[report] %s (%d): %s'):format(GetPlayerName(src), src, message))
end)

source is a global that FiveM sets to the server ID of the player who triggered the event. Copy it into a local at the top of the handler. After any Wait or async call, the global may belong to a different event.

Server to client

server.lua
TriggerClientEvent('myres:notify', src, 'Your report was sent')   -- one player
TriggerClientEvent('myres:notify', -1, 'Server restart in 5 min') -- everyone
client.lua
RegisterNetEvent('myres:notify', function(text)
    BeginTextCommandThefeedPost('STRING')
    AddTextComponentSubstringPlayerName(text)
    EndTextCommandThefeedPostTicker(false, false)
end)

RegisterNetEvent(name, handler) both allows the event over the network and adds the handler. The older two step form (RegisterNetEvent(name) then AddEventHandler(name, fn)) does the same thing.

Note

Without RegisterNetEvent, network triggers of that event are ignored. That’s a safety feature: only events you explicitly register can be called from the other side.

Big payloads

Events are for small data. For large data (a big table, a long string), use TriggerLatentClientEvent(name, target, bytesPerSecond, ...) or TriggerLatentServerEvent, which send it in the background without choking the connection. Or rethink: maybe the client only needs part of it.

Getting an answer back: callbacks

Events are fire and forget. When the client needs an answer (“can I afford this?”), use a callback. FiveM has no built in client/server callback, so use ox_lib’s:

server.lua
lib.callback.register('myres:getBalance', function(source)
    local player = exports.qbx_core:GetPlayer(source)   -- or your framework
    return player and player.PlayerData.money.bank or 0
end)
client.lua
local balance = lib.callback.await('myres:getBalance', false)
print('Bank balance: ' .. balance)

Both files need shared_script '@ox_lib/init.lua' in the manifest and ox_lib started. QBCore (QBCore.Functions.CreateCallback / TriggerCallback) and ESX (ESX.RegisterServerCallback / ESX.TriggerServerCallback) have their own versions.

Security: never trust the client

A cheater with an executor can call any net event you registered, with any arguments, as often as they like. So every server handler must assume the worst.

Bad

server.lua (DON'T)
RegisterNetEvent('fishing:sell', function(amount, price)
    local player = GetPlayer(source)
    player.addMoney(amount * price)     -- client decided the price and the amount
end)

Good

server.lua
local FISH_PRICE = 25
local SELL_POINT = vector3(-1847.0, -1195.0, 14.3)
local lastSell = {}

RegisterNetEvent('fishing:sell', function()
    local src = source
    local player = GetPlayer(src)
    if not player then return end

    -- rate limit
    local now = os.time()
    if lastSell[src] and now - lastSell[src] < 2 then return end
    lastSell[src] = now

    -- position check on the server (OneSync)
    local ped = GetPlayerPed(src)
    if #(GetEntityCoords(ped) - SELL_POINT) > 5.0 then return end

    -- the server counts what the player has
    local count = player.getItemCount('fish')
    if count <= 0 then return end

    player.removeItem('fish', count)
    player.addMoney(count * FISH_PRICE)
end)

AddEventHandler('playerDropped', function()
    lastSell[source] = nil
end)

The rules:

  1. The server decides values: prices, rewards, amounts, item names come from server config, not from arguments.
  2. Use source to know who’s asking. Never accept a player ID as an argument to act on “yourself”.
  3. Validate arguments: type, range, length, whitelist (if not ALLOWED[item] then return end).
  4. Check the situation: distance (OneSync lets the server read GetEntityCoords(GetPlayerPed(src))), job, cooldown, whether they actually have the item.
  5. Rate limit anything that gives something.
  6. Don’t trigger server events from client “loops” that give rewards. Give rewards when the server confirms the action.
  7. Log money and item changes, so you can find abuse later.

Useful built in events

Event Side When
playerConnecting Server A player starts connecting. Lets you defer, check bans, show adaptive cards.
playerJoining Server The player got a server ID and is joining.
playerDropped Server A player left. source is the player, first argument is the reason.
onResourceStart / onResourceStop Both Any resource started or stopped. Check GetCurrentResourceName() == resourceName to react to your own.
onClientResourceStart Client A resource started on this client.
gameEventTriggered Client Game events like CEventNetworkEntityDamage. See Game Damage Events for kills, deaths and vehicle damage.
server.lua
AddEventHandler('playerDropped', function(reason)
    local src = source
    print(('%s left: %s'):format(GetPlayerName(src), reason))
end)

AddEventHandler('onResourceStop', function(resourceName)
    if resourceName ~= GetCurrentResourceName() then return end
    -- save state before the resource stops
end)

Commands as the entry point

RegisterCommand works on both sides. A client command that needs the server should just send an event, and let the server check permissions:

server.lua
RegisterCommand('heal', function(source, args)
    local target = tonumber(args[1]) or source
    if not IsPlayerAceAllowed(source, 'myres.heal') then return end
    TriggerClientEvent('myres:heal', target)
end, false)
server.cfg
add_ace group.admin myres.heal allow

Common mistakes

  • Registering a client event with AddEventHandler only, then triggering it from the server: nothing happens, it isn’t a net event.
  • Using source after a Wait() or inside a callback: copy it first.
  • Sending the full player list or huge tables every second: send changes only, or use state bags.
  • Trusting GetPlayerName for identity: names can be anything. Use identifiers or your framework’s citizen ID.

Next: Natives.