Client and Server
Every synchronized resource can have a client VM, a server VM, or both. Treat the network boundary as a trust boundary.
Choose the owning side
| Work | Side |
|---|---|
| Input, local UI, visual previews | Client |
| Inventory or container mutation | Server |
| Permissions and rewards | Server |
| Persistent state | Server |
| Local player notification | Client or server targeting a client |
Network event flow
lua
-- client.lua
RegisterCommand('claim_reward', function()
TriggerServerEvent('rewards:claim', 'daily')
end, false)lua
-- server.lua
RegisterNetEvent('rewards:claim', function(rewardId)
local peerId = source
if rewardId ~= 'daily' then
return
end
if not Player.HasPermission(peerId, 'rewards.claim') then
return
end
local revision = Inventory.GetRevision(peerId)
Inventory.AddItem(peerId, 'Coins', 10, 1, revision)
end)The server handler validates the requested reward, checks permission, and performs the mutation. Never accept an item prefab, quantity, permission result, position, or player identity from the client without server-side validation.
The source value
Inside a network event handler, global source is the sending peer ID. Capture it before starting asynchronous work:
lua
RegisterNetEvent('profile:load', function()
local peerId = source
MySQL.single('SELECT title FROM profiles WHERE steam_id = @id', {
['@id'] = Player.GetSteamId(peerId)
}, function(row)
if row then
TriggerClientEvent('profile:title', peerId, row.title)
end
end)
end)Local events and exports
Use TriggerEvent for decoupled work on the same side. Use exports for a synchronous resource-to-resource contract:
lua
-- provider/server.lua
exports('getMultiplier', function()
return 1.25
end)
-- consumer/server.lua
local multiplier = exports['provider'].getMultiplier() or 1Exports cannot cross the client/server boundary.