FiveM Docs
Getting Started

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

KindConventionExample
Local variables and functionscamelCaselocal playerCount, local function getClosestShop()
Globals (avoid them)PascalCaseConfig, MyResource
ConstantsUPPER_SNAKE_CASElocal MAX_DISTANCE <const> = 3.0
Eventsresource:actionmy_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 block

Use 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:

server/main.lua
---@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:

client/main.lua
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:

client/main.lua
-- 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:

client/main.lua
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()
end

esx: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.

client/main.lua
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

OutdatedUse insteadWhy
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] = vAvoids a function call in hot loops
Citizen.CreateThread, Citizen.WaitCreateThread, WaitShorter aliases, same behavior
TriggerEvent('esx:getSharedObject', cb)@es_extended/imports.luaThe 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 __close metamethod when it goes out of scope, useful for cleanup.
  • Integer division // and native bitwise operators &, |, ~, <<, >>.
  • goto continue to 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:

  1. Every net event is public. Any client can trigger any RegisterNetEvent with any data. Validate types, ranges and permissions on the server.
  2. The server decides values. Never accept prices, amounts of money or item names chosen by the client when the server can look them up.
  3. Check context. Is the player near the shop (xPlayer.getCoords(true))? Do they have the job? Is the target player close?
  4. Rate-limit expensive actions and log suspicious ones.
server/main.lua
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)

Frequently asked questions

Independent documentation

esx-fivem.com is a community project and is not affiliated with the ESX Framework team. Facts on this page were checked against the upstream source. Official resources: docs.esx-framework.org and github.com/esx-framework.
Edit on GitHub

Last updated on

On this page