Server-Side Development
Complete guide to developing server-side scripts for FiveM resources.
Server-side scripts handle game logic, player management, database operations, and security validation. They run on the server and communicate with clients through events.
Server Script Basics
Script Structure
-- server.lua
local playerData = {}
local resourceConfig = {}
-- Resource initialization
AddEventHandler('onResourceStart', function(resourceName)
if resourceName == GetCurrentResourceName() then
print('[' .. resourceName .. '] Resource started successfully')
InitializeResource()
end
end)
-- Resource cleanup
AddEventHandler('onResourceStop', function(resourceName)
if resourceName == GetCurrentResourceName() then
print('[' .. resourceName .. '] Resource stopped')
CleanupResource()
end
end)
function InitializeResource()
-- Load configuration
-- Initialize database
-- Setup recurring tasks
end
Player Management
-- Player connection events
AddEventHandler('playerConnecting', function(name, setKickReason, deferrals)
local src = source
local identifiers = GetPlayerIdentifiers(src)
deferrals.defer()
deferrals.update('Checking player data...')
-- Validate player
if IsPlayerBanned(identifiers) then
deferrals.done('You are banned from this server.')
return
end
deferrals.done()
end)
AddEventHandler('playerJoining', function(name)
local src = source
print(('[%s] %s is joining the server'):format(src, name))
end)
AddEventHandler('playerDropped', function(reason)
local src = source
print(('[%s] %s left the server: %s'):format(src, GetPlayerName(src), reason))
-- Save player data before disconnect
SavePlayerData(src)
playerData[src] = nil
end)
Player Data Functions
function GetPlayerIdentifiers(src)
local identifiers = {}
for i = 0, GetNumPlayerIdentifiers(src) - 1 do
local id = GetPlayerIdentifier(src, i)
local prefix = string.match(id, '([^:]+):')
identifiers[prefix] = id
end
return identifiers
end
function GetPlayerByIdentifier(identifier)
for _, playerId in ipairs(GetPlayers()) do
local playerIds = GetPlayerIdentifiers(playerId)
for _, id in pairs(playerIds) do
if id == identifier then
return tonumber(playerId)
end
end
end
return nil
end
function GetPlayerData(src)
return playerData[src] or {}
end
function SetPlayerData(src, key, value)
if not playerData[src] then
playerData[src] = {}
end
playerData[src][key] = value
TriggerClientEvent('playerData:updated', src, key, value)
end
Event Handling
Server Events
-- Register server events
RegisterServerEvent('myresource:saveData')
AddEventHandler('myresource:saveData', function(data)
local src = source
-- Validate source
if not src or src == 0 then return end
-- Validate data
if not data or type(data) ~= 'table' then
print('Invalid data received from player ' .. src)
return
end
-- Process data
SavePlayerData(src, data)
end)
-- Command events
RegisterCommand('heal', function(source, args, rawCommand)
if source == 0 then
print('This command can only be used in-game')
return
end
-- Check permissions
if not IsPlayerAdmin(source) then
TriggerClientEvent('chat:addMessage', source, {
color = {255, 0, 0},
args = {'System', 'You do not have permission to use this command'}
})
return
end
-- Execute command
TriggerClientEvent('myresource:heal', source)
end, false)
Event Broadcasting
-- Send to specific player
function NotifyPlayer(src, message, type)
TriggerClientEvent('myresource:notify', src, message, type)
end
-- Send to all players
function NotifyAll(message, type)
TriggerClientEvent('myresource:notify', -1, message, type)
end
-- Send to players with permission
function NotifyAdmins(message)
for _, playerId in ipairs(GetPlayers()) do
if IsPlayerAdmin(playerId) then
NotifyPlayer(playerId, message, 'admin')
end
end
end
-- Send to players in area
function NotifyPlayersInArea(coords, radius, message)
for _, playerId in ipairs(GetPlayers()) do
local playerCoords = GetEntityCoords(GetPlayerPed(playerId))
local distance = #(coords - playerCoords)
if distance <= radius then
NotifyPlayer(playerId, message, 'area')
end
end
end
Database Integration
MySQL Example
-- Using mysql-async
MySQL = exports['mysql-async']
function LoadPlayerData(src, identifier)
MySQL.Async.fetchAll('SELECT * FROM users WHERE identifier = @identifier', {
['@identifier'] = identifier
}, function(result)
if result[1] then
playerData[src] = result[1]
TriggerClientEvent('playerData:loaded', src, playerData[src])
else
CreateNewPlayer(src, identifier)
end
end)
end
function SavePlayerData(src, data)
local identifier = GetPlayerIdentifier(src, 0)
MySQL.Async.execute('UPDATE users SET money = @money, job = @job WHERE identifier = @identifier', {
['@money'] = data.money,
['@job'] = data.job,
['@identifier'] = identifier
}, function(affectedRows)
if affectedRows > 0 then
print('Player data saved for ' .. GetPlayerName(src))
end
end)
end
function CreateNewPlayer(src, identifier)
MySQL.Async.execute('INSERT INTO users (identifier, name, money, job) VALUES (@identifier, @name, @money, @job)', {
['@identifier'] = identifier,
['@name'] = GetPlayerName(src),
['@money'] = 5000,
['@job'] = 'unemployed'
}, function(insertId)
if insertId then
playerData[src] = {
id = insertId,
identifier = identifier,
name = GetPlayerName(src),
money = 5000,
job = 'unemployed'
}
TriggerClientEvent('playerData:loaded', src, playerData[src])
end
end)
end
OxMySQL Example
-- Using oxmysql
local MySQL = exports.oxmysql
function LoadPlayerDataOx(src, identifier)
MySQL:single('SELECT * FROM users WHERE identifier = ?', {identifier}, function(result)
if result then
playerData[src] = result
TriggerClientEvent('playerData:loaded', src, playerData[src])
else
CreateNewPlayerOx(src, identifier)
end
end)
end
function SavePlayerDataOx(src, data)
local identifier = GetPlayerIdentifier(src, 0)
MySQL:update('UPDATE users SET money = ?, job = ? WHERE identifier = ?',
{data.money, data.job, identifier}, function(affectedRows)
if affectedRows > 0 then
print('Player data saved for ' .. GetPlayerName(src))
end
end)
end
Security and Validation
Input Validation
function ValidateInput(data, schema)
for key, rules in pairs(schema) do
local value = data[key]
-- Check required fields
if rules.required and (value == nil or value == '') then
return false, 'Missing required field: ' .. key
end
-- Check data types
if value ~= nil and rules.type and type(value) ~= rules.type then
return false, 'Invalid type for field: ' .. key
end
-- Check string length
if rules.maxLength and type(value) == 'string' and #value > rules.maxLength then
return false, 'Field too long: ' .. key
end
-- Check numeric ranges
if rules.min and type(value) == 'number' and value < rules.min then
return false, 'Value too small for field: ' .. key
end
if rules.max and type(value) == 'number' and value > rules.max then
return false, 'Value too large for field: ' .. key
end
end
return true, nil
end
-- Usage
local transferSchema = {
amount = {required = true, type = 'number', min = 1, max = 1000000},
targetId = {required = true, type = 'number'},
reason = {type = 'string', maxLength = 100}
}
RegisterServerEvent('banking:transfer')
AddEventHandler('banking:transfer', function(data)
local src = source
local valid, error = ValidateInput(data, transferSchema)
if not valid then
TriggerClientEvent('banking:error', src, 'Invalid input: ' .. error)
return
end
-- Process transfer
ProcessTransfer(src, data.targetId, data.amount, data.reason)
end)
Permission System
local permissions = {}
function LoadPermissions()
-- Load from database or config file
permissions = {
admin = {'kick', 'ban', 'noclip', 'heal', 'money'},
moderator = {'kick', 'heal'},
vip = {'heal'},
player = {}
}
end
function GetPlayerRole(src)
-- Get from database or identifiers
local identifier = GetPlayerIdentifier(src, 0)
-- Return role based on database lookup
return 'player' -- Default role
end
function HasPermission(src, permission)
local role = GetPlayerRole(src)
local rolePerms = permissions[role] or {}
for _, perm in ipairs(rolePerms) do
if perm == permission then
return true
end
end
return false
end
-- Usage in commands
RegisterCommand('money', function(source, args, rawCommand)
if not HasPermission(source, 'money') then
TriggerClientEvent('chat:addMessage', source, {
color = {255, 0, 0},
args = {'System', 'No permission'}
})
return
end
local amount = tonumber(args[1])
if amount then
GivePlayerMoney(source, amount)
end
end, false)
Anti-Cheat Measures
local playerActions = {}
function LogPlayerAction(src, action, data)
local timestamp = os.time()
if not playerActions[src] then
playerActions[src] = {}
end
table.insert(playerActions[src], {
action = action,
data = data,
timestamp = timestamp
})
-- Keep only last 100 actions
if #playerActions[src] > 100 then
table.remove(playerActions[src], 1)
end
end
function CheckSpamming(src, action, timeLimit, maxActions)
if not playerActions[src] then return false end
local currentTime = os.time()
local actionCount = 0
for i = #playerActions[src], 1, -1 do
local log = playerActions[src][i]
if currentTime - log.timestamp > timeLimit then
break
end
if log.action == action then
actionCount = actionCount + 1
end
end
return actionCount >= maxActions
end
-- Usage
RegisterServerEvent('myresource:buyItem')
AddEventHandler('myresource:buyItem', function(itemId, quantity)
local src = source
-- Check for spamming
if CheckSpamming(src, 'buyItem', 60, 10) then -- 10 purchases per minute max
TriggerClientEvent('myresource:error', src, 'Too many purchase attempts')
return
end
LogPlayerAction(src, 'buyItem', {item = itemId, qty = quantity})
-- Process purchase
ProcessPurchase(src, itemId, quantity)
end)
Performance Optimization
Efficient Player Loops
-- Bad: Process all players every frame
Citizen.CreateThread(function()
while true do
Citizen.Wait(0)
for _, playerId in ipairs(GetPlayers()) do
ProcessPlayer(playerId)
end
end
end)
-- Good: Process players with intervals
Citizen.CreateThread(function()
while true do
Citizen.Wait(5000) -- Every 5 seconds
for _, playerId in ipairs(GetPlayers()) do
ProcessPlayerPeriodic(playerId)
end
end
end)
-- Better: Staggered processing
Citizen.CreateThread(function()
while true do
local players = GetPlayers()
for i, playerId in ipairs(players) do
Citizen.Wait(100) -- 100ms delay between players
ProcessPlayer(playerId)
end
Citizen.Wait(1000) -- 1 second before next cycle
end
end)
Caching and Memoization
local cache = {}
local cacheTimeout = 60000 -- 1 minute
function GetCachedData(key, fetchFunction)
local now = GetGameTimer()
if cache[key] and (now - cache[key].timestamp) < cacheTimeout then
return cache[key].data
end
local data = fetchFunction()
cache[key] = {
data = data,
timestamp = now
}
return data
end
-- Usage
function GetPlayerBankBalance(src)
return GetCachedData('bank_' .. src, function()
-- This expensive database call only happens once per minute
return MySQL.Sync.fetchScalar('SELECT money FROM users WHERE id = @id', {
['@id'] = GetPlayerData(src).id
})
end)
end
Common Patterns
Resource Communication
-- Export functions for other resources
function GetPlayerMoney(src)
return GetPlayerData(src).money or 0
end
function SetPlayerMoney(src, amount)
SetPlayerData(src, 'money', amount)
SavePlayerData(src)
end
-- Register exports
exports('GetPlayerMoney', GetPlayerMoney)
exports('SetPlayerMoney', SetPlayerMoney)
-- Use other resource exports
function TransferToBank(src, amount)
local success = exports.banking:DepositMoney(src, amount)
if success then
SetPlayerMoney(src, GetPlayerMoney(src) - amount)
end
return success
end
Job System
local jobs = {
police = {
name = 'Police Officer',
salary = 2500,
grades = {
[0] = {name = 'Cadet', salary = 2000},
[1] = {name = 'Officer', salary = 2500},
[2] = {name = 'Sergeant', salary = 3000},
[3] = {name = 'Lieutenant', salary = 3500},
[4] = {name = 'Chief', salary = 4000}
}
},
mechanic = {
name = 'Mechanic',
salary = 2000,
grades = {
[0] = {name = 'Apprentice', salary = 1800},
[1] = {name = 'Mechanic', salary = 2000},
[2] = {name = 'Supervisor', salary = 2500}
}
}
}
function SetPlayerJob(src, jobName, grade)
grade = grade or 0
if not jobs[jobName] then
print('Invalid job: ' .. tostring(jobName))
return false
end
if not jobs[jobName].grades[grade] then
print('Invalid grade for job ' .. jobName .. ': ' .. tostring(grade))
return false
end
SetPlayerData(src, 'job', jobName)
SetPlayerData(src, 'job_grade', grade)
TriggerClientEvent('job:updated', src, jobName, grade)
return true
end
function GetPlayerJob(src)
local jobName = GetPlayerData(src).job or 'unemployed'
local grade = GetPlayerData(src).job_grade or 0
return jobName, grade, jobs[jobName]
end
Economy System
local economy = {
items = {},
shops = {},
transactions = {}
}
function AddMoney(src, amount, reason)
if amount <= 0 then return false end
local currentMoney = GetPlayerMoney(src)
SetPlayerMoney(src, currentMoney + amount)
LogTransaction(src, 'add', amount, reason)
TriggerClientEvent('money:updated', src, currentMoney + amount)
return true
end
function RemoveMoney(src, amount, reason)
if amount <= 0 then return false end
local currentMoney = GetPlayerMoney(src)
if currentMoney < amount then
return false, 'Insufficient funds'
end
SetPlayerMoney(src, currentMoney - amount)
LogTransaction(src, 'remove', amount, reason)
TriggerClientEvent('money:updated', src, currentMoney - amount)
return true
end
function LogTransaction(src, type, amount, reason)
table.insert(economy.transactions, {
playerId = src,
type = type,
amount = amount,
reason = reason,
timestamp = os.time()
})
-- Save to database
MySQL.Async.execute('INSERT INTO transactions (player_id, type, amount, reason, timestamp) VALUES (@player_id, @type, @amount, @reason, @timestamp)', {
['@player_id'] = src,
['@type'] = type,
['@amount'] = amount,
['@reason'] = reason,
['@timestamp'] = os.time()
})
end
Debugging
Logging System
local LogLevel = {
DEBUG = 1,
INFO = 2,
WARN = 3,
ERROR = 4
}
local currentLogLevel = LogLevel.INFO
function Log(level, message, ...)
if level < currentLogLevel then return end
local levelNames = {'DEBUG', 'INFO', 'WARN', 'ERROR'}
local levelName = levelNames[level] or 'UNKNOWN'
local timestamp = os.date('%Y-%m-%d %H:%M:%S')
local formattedMessage = string.format(message, ...)
print(string.format('[%s][%s] %s', timestamp, levelName, formattedMessage))
-- Also save to file if needed
if level >= LogLevel.ERROR then
SaveToLogFile(timestamp, levelName, formattedMessage)
end
end
-- Usage
Log(LogLevel.DEBUG, 'Player %s connected with identifier %s', GetPlayerName(src), identifier)
Log(LogLevel.ERROR, 'Database connection failed: %s', error)