Coding Practices for ESX
Best practices for ESX Legacy scripts - naming, locals vs globals, efficient loops, event-driven code, caching, modern natives, Lua 5.4 features and server security.
These are the conventions and techniques that keep ESX scripts readable, fast and hard to exploit. They apply to any FiveM Lua resource, with examples that use the ESX API. If you are new to ESX, build your first resource first and come back here.
Style
Naming
| Kind | Convention | Example |
|---|---|---|
| Local variables and functions | camelCase | local playerCount, local function getClosestShop() |
| Globals (avoid them) | PascalCase | Config, MyResource |
| Constants | UPPER_SNAKE_CASE | local MAX_DISTANCE <const> = 3.0 |
| Events | resource:action | my_shop:buyItem |
Prefix every event and callback with your resource name. Two scripts that both register buyItem will fight each other; my_shop:buyItem will not.
Formatting
Pick one indentation width (the ESX core uses 4 spaces) and stay consistent. Use a formatter such as StyLua so you never argue about it again.
Comments and annotations
Comment why something is done, not what the next line does. For functions other people will call, add LuaLS annotations so editors can show types and catch mistakes. See Annotations.
Locals over globals
MyCounter = 0 -- global: visible to every file in the resource
local myCounter = 0 -- local: visible only in this file or blockUse local for everything unless you deliberately share a value between files of the same resource. Locals are faster to access, cannot be overwritten by another file by accident and are freed when they go out of scope. Functions are values too, so local function is the default.
A local function must be defined before the code that calls it:
-- Wrong: startThread is nil at the time it is called
startThread()
local function startThread() end
-- Right
local function startThread() end
startThread()Don't repeat yourself
If you copy the same five lines into three event handlers, move them into a function. In ESX scripts the usual candidates are "get the player and check the job" blocks:
---@return table? xPlayer
local function getPoliceOfficer(source)
local xPlayer = ESX.GetPlayerFromId(source)
if xPlayer and xPlayer.job.name == 'police' then
return xPlayer
end
end
RegisterNetEvent('my_police:cuff', function(targetId)
local officer = getPoliceOfficer(source)
if not officer then return end
-- ...
end)Loops and threads
Always wait
A while loop without Wait never yields. On the client the game freezes, on the server every resource stops. Every loop that repeats must call Wait.
Wait as long as you can
Wait(0) runs every frame. Only code that must react every frame needs it: drawing markers or text, or reading controls with IsControlJustPressed. Everything else can run much less often. A common pattern is a dynamic sleep that is long when the player is far away and short when close:
local SHOP_COORDS <const> = vector3(25.7, -1347.3, 29.5)
CreateThread(function()
while true do
local sleep = 1000
local distance = #(GetEntityCoords(PlayerPedId()) - SHOP_COORDS)
if distance < 10.0 then
sleep = 0
DrawMarker(1, SHOP_COORDS.x, SHOP_COORDS.y, SHOP_COORDS.z - 1.0, 0, 0, 0, 0, 0, 0, 1.0, 1.0, 0.5, 50, 150, 255, 150, false, false, 2, false, nil, nil, false)
if distance < 1.5 and IsControlJustPressed(0, 38) then -- E
TriggerEvent('my_shop:open')
end
end
Wait(sleep)
end
end)ESX also ships a helper for this pattern, documented under Point.
React to events instead of polling
The classic mistake is a thread that checks the job every frame forever:
-- Wasteful: runs every frame for every player, even non-police
CreateThread(function()
while true do
Wait(0)
if ESX.PlayerData.job and ESX.PlayerData.job.name == 'police' then
if IsControlJustPressed(0, 38) then
-- ...
end
end
end
end)Only run the loop while the player actually has the job, and restart it when the job changes:
local policeThreadRunning = false
local function isPolice()
return ESX.PlayerData.job and ESX.PlayerData.job.name == 'police'
end
local function startPoliceThread()
if policeThreadRunning then return end
policeThreadRunning = true
CreateThread(function()
while isPolice() do
if IsControlJustPressed(0, 38) then
-- ...
end
Wait(0)
end
policeThreadRunning = false
end)
end
-- Job changed while playing
RegisterNetEvent('esx:setJob', function(job, lastJob)
if job.name == 'police' then
startPoliceThread()
end
end)
-- Character loaded (or resource restarted while already in game)
RegisterNetEvent('esx:playerLoaded', function()
if isPolice() then startPoliceThread() end
end)
if ESX.PlayerLoaded and isPolice() then
startPoliceThread()
endesx:setJob reaches the client with the new job and the previous job. The imports.lua file keeps ESX.PlayerData.job in sync, so the loop condition sees the change on its own.
For key presses, consider RegisterKeyMapping with a command instead of a loop at all. It costs nothing while idle and players can rebind the key.
Cache expensive values
Store values that do not change often instead of asking the game again and again.
local playerId = PlayerId() -- never changes during a session
local serverId = GetPlayerServerId(playerId)
local ped = PlayerPedId()
-- The ped changes on respawn or model change; ESX tells you when
AddEventHandler('esx:playerPedChanged', function(newPed)
ped = newPed
end)With @es_extended/imports.lua loaded, ESX.PlayerData.ped is also kept current, and ESX.PlayerData.coords returns the live position of that ped.
Cache only what you use and clear entries you no longer need. A table that grows by one entry per player who ever joined is a memory leak.
Use current natives and Lua idioms
| Outdated | Use instead | Why |
|---|---|---|
GetPlayerPed(-1) | PlayerPedId() | Direct, no lookup by player index |
GetDistanceBetweenCoords(x1, y1, z1, x2, y2, z2, true) | #(vec1 - vec2) | Vector math runs in Lua without a native call |
GetHashKey('adder') | `adder` or joaat('adder') | Backtick strings are hashed at compile time in CfxLua |
table.insert(t, v) | t[#t + 1] = v | Avoids a function call in hot loops |
Citizen.CreateThread, Citizen.Wait | CreateThread, Wait | Shorter aliases, same behavior |
TriggerEvent('esx:getSharedObject', cb) | @es_extended/imports.lua | The event-based getter is legacy |
Do not replace table.remove with nil
Some guides suggest t[i] = nil instead of table.remove(t, i). For arrays that is a bug: it leaves a hole, after which #t and ipairs stop at the wrong place. Use table.remove for arrays. Setting nil is only correct for keyed tables like players[source] = nil.
Lua 5.4
Add lua54 'yes' to your fxmanifest.lua. All ESX core resources use it. Useful features:
<const>marks a local as constant. Assigning to it is a compile error.local MAX_PLAYERS <const> = 64<close>runs the value's__closemetamethod when it goes out of scope, useful for cleanup.- Integer division
//and native bitwise operators&,|,~,<<,>>. goto continueto skip to the next loop iteration.- A faster VM and a generational garbage collector.
CfxLua also adds extras on top of Lua 5.4, such as compound assignment (count += 1) and backtick hashing. They work in FiveM but not in a plain Lua interpreter.
Server security
Performance problems slow a server down; security problems empty its economy. The rules:
- Every net event is public. Any client can trigger any
RegisterNetEventwith any data. Validate types, ranges and permissions on the server. - The server decides values. Never accept prices, amounts of money or item names chosen by the client when the server can look them up.
- Check context. Is the player near the shop (
xPlayer.getCoords(true))? Do they have the job? Is the target player close? - Rate-limit expensive actions and log suspicious ones.
local SHOP_COORDS <const> = vector3(25.7, -1347.3, 29.5)
local PRICES <const> = { water = 25, bread = 15 }
RegisterNetEvent('my_shop:buy', function(itemName)
local xPlayer = ESX.GetPlayerFromId(source)
if not xPlayer then return end
local price = PRICES[itemName]
if not price then return end -- unknown item: ignore
if #(xPlayer.getCoords(true) - SHOP_COORDS) > 5.0 then return end
if xPlayer.canCarryItem(itemName, 1) and xPlayer.removeAccountMoney('money', price, 'Shop purchase') then
xPlayer.addInventoryItem(itemName, 1)
end
end)Related
Frequently asked questions
Independent documentation
Last updated on
Your First ESX Resource
Build your first ESX Legacy resource step by step - fxmanifest with imports.lua, a server command with xPlayer, client notifications and a server callback.
Lua Annotations for ESX
Use LuaLS annotations in ESX scripts - document functions, classes and types, reuse the xPlayer and ESXJob types from es_extended, and set up VS Code for autocomplete.