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.
Diese Doku ist vorerst auf Englisch.
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.
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:
-- 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
RegisterCommand('report', function(_, args)
local message = table.concat(args, ' ')
TriggerServerEvent('myres:report', message)
end, false)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
TriggerClientEvent('myres:notify', src, 'Your report was sent') -- one player
TriggerClientEvent('myres:notify', -1, 'Server restart in 5 min') -- everyoneRegisterNetEvent('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:
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)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
RegisterNetEvent('fishing:sell', function(amount, price)
local player = GetPlayer(source)
player.addMoney(amount * price) -- client decided the price and the amount
end)Good
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:
- The server decides values: prices, rewards, amounts, item names come from server config, not from arguments.
- Use
sourceto know who’s asking. Never accept a player ID as an argument to act on “yourself”. - Validate arguments: type, range, length, whitelist (
if not ALLOWED[item] then return end). - Check the situation: distance (OneSync lets the server read
GetEntityCoords(GetPlayerPed(src))), job, cooldown, whether they actually have the item. - Rate limit anything that gives something.
- Don’t trigger server events from client “loops” that give rewards. Give rewards when the server confirms the action.
- 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. |
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:
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)add_ace group.admin myres.heal allowCommon mistakes
- Registering a client event with
AddEventHandleronly, then triggering it from the server: nothing happens, it isn’t a net event. - Using
sourceafter aWait()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
GetPlayerNamefor identity: names can be anything. Use identifiers or your framework’s citizen ID.
Next: Natives.
