Why Reputation Systems Transform Roleplay
Most FiveM servers treat character progression as binary: you either have a job or you do not. There is no sense of growth, mastery, or earned status. A reputation and skills system changes this fundamentally by giving players measurable progression that rewards time invested and activities completed. When a mechanic repairs their hundredth vehicle and unlocks advanced engine tuning, that achievement feels earned. When a criminal builds enough street reputation to access high-tier heist equipment, there is a tangible sense of progression driving their gameplay forward. In this guide, we will build a complete reputation and skills framework from scratch, covering XP calculations, skill trees, reputation tiers, unlockable abilities, and a prestige system that keeps endgame players engaged.
Database Schema for Player Skills
The foundation of any progression system is a well-designed database schema. You need tables that track individual skill XP, reputation scores per faction or activity, and unlocked abilities. The schema should be normalized enough to be queryable but denormalized enough to avoid expensive joins on every skill check. We use a single player_skills table with a composite key of citizen ID and skill name, plus a separate player_reputation table for faction standing. This separation keeps skills (personal ability) distinct from reputation (how NPCs and factions perceive you), which enables more nuanced gameplay interactions.
-- SQL schema
CREATE TABLE IF NOT EXISTS player_skills (
citizenid VARCHAR(50) NOT NULL,
skill_name VARCHAR(50) NOT NULL,
xp INT DEFAULT 0,
level INT DEFAULT 1,
prestige INT DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (citizenid, skill_name)
);
CREATE TABLE IF NOT EXISTS player_reputation (
citizenid VARCHAR(50) NOT NULL,
faction VARCHAR(50) NOT NULL,
reputation INT DEFAULT 0,
tier VARCHAR(20) DEFAULT 'neutral',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (citizenid, faction)
);
XP Calculation and Leveling Curves
A flat XP curve where every level requires the same amount of experience feels unrewarding because early levels fly by too fast while later levels feel identical. The best approach uses a polynomial curve where each level requires progressively more XP, but the increase is not so steep that max level becomes unreachable. A common formula is requiredXP = baseXP * (level ^ exponent) where a base of 100 and an exponent of 1.5 creates a smooth curve. Level 1 requires 100 XP, level 10 requires about 3,162 XP, and level 50 requires roughly 35,355 XP. This keeps early progression snappy while making higher levels feel like genuine achievements that take dedicated play to reach.
-- shared/skills_config.lua
SkillsConfig = {}
SkillsConfig.BaseXP = 100
SkillsConfig.Exponent = 1.5
SkillsConfig.MaxLevel = 50
function SkillsConfig.GetRequiredXP(level)
return math.floor(SkillsConfig.BaseXP * (level ^ SkillsConfig.Exponent))
end
function SkillsConfig.GetTotalXPForLevel(targetLevel)
local total = 0
for i = 1, targetLevel - 1 do
total = total + SkillsConfig.GetRequiredXP(i)
end
return total
end
-- Skill definitions with XP sources
SkillsConfig.Skills = {
driving = {
label = 'Driving',
icon = 'fa-car',
xpSources = {
{ action = 'distance_driven', xpPer = 1, unit = 'per 500m' },
{ action = 'race_won', xpPer = 150, unit = 'per win' },
{ action = 'no_crash_streak', xpPer = 50, unit = 'per 5min' },
}
},
shooting = {
label = 'Marksmanship',
icon = 'fa-crosshairs',
xpSources = {
{ action = 'headshot', xpPer = 25, unit = 'per kill' },
{ action = 'bodyshot', xpPer = 10, unit = 'per kill' },
{ action = 'range_practice', xpPer = 5, unit = 'per target' },
}
},
mechanic = {
label = 'Mechanic',
icon = 'fa-wrench',
xpSources = {
{ action = 'vehicle_repaired', xpPer = 30, unit = 'per repair' },
{ action = 'engine_swap', xpPer = 100, unit = 'per swap' },
{ action = 'custom_tune', xpPer = 75, unit = 'per tune' },
}
},
cooking = {
label = 'Cooking',
icon = 'fa-utensils',
xpSources = {
{ action = 'meal_cooked', xpPer = 20, unit = 'per meal' },
{ action = 'recipe_discovered', xpPer = 50, unit = 'per recipe' },
}
},
stamina = {
label = 'Stamina',
icon = 'fa-running',
xpSources = {
{ action = 'distance_sprinted', xpPer = 1, unit = 'per 200m' },
{ action = 'swimming', xpPer = 2, unit = 'per 100m' },
}
},
}
Server-Side XP Management
All XP modifications must happen server-side to prevent exploits. The server validates every XP grant request, checks for rate limiting to prevent spam, applies any active multipliers, and persists the result to the database. A critical design decision is whether to save on every XP change or batch writes. Saving on every tiny XP gain hammers the database, especially for passive skills like driving distance. The solution is an in-memory cache that periodically flushes to the database, combined with immediate saves for significant events like level-ups. This gives you real-time accuracy for the player while keeping database load manageable.
-- server/skills_manager.lua
local playerCache = {}
local SAVE_INTERVAL = 60000 -- flush to DB every 60 seconds
-- Load player skills from DB on join
AddEventHandler('playerJoining', function()
local src = source
local citizenid = GetCitizenId(src)
if not citizenid then return end
local skills = MySQL.query.await('SELECT * FROM player_skills WHERE citizenid = ?', {citizenid})
playerCache[citizenid] = {}
for _, row in ipairs(skills or {}) do
playerCache[citizenid][row.skill_name] = {
xp = row.xp,
level = row.level,
prestige = row.prestige,
dirty = false
}
end
end)
function AddSkillXP(citizenid, skillName, amount)
if not SkillsConfig.Skills[skillName] then return end
if not playerCache[citizenid] then return end
local skill = playerCache[citizenid][skillName]
if not skill then
skill = { xp = 0, level = 1, prestige = 0, dirty = false }
playerCache[citizenid][skillName] = skill
end
-- Apply prestige multiplier (each prestige = +5% XP)
local multiplier = 1.0 + (skill.prestige * 0.05)
local finalXP = math.floor(amount * multiplier)
skill.xp = skill.xp + finalXP
skill.dirty = true
-- Check for level up
local required = SkillsConfig.GetRequiredXP(skill.level)
while skill.xp >= required and skill.level < SkillsConfig.MaxLevel do
skill.xp = skill.xp - required
skill.level = skill.level + 1
required = SkillsConfig.GetRequiredXP(skill.level)
-- Notify player of level up
local src = GetPlayerByCitizenId(citizenid)
if src then
TriggerClientEvent('skills:levelUp', src, skillName, skill.level)
end
-- Immediate save on level up
SaveSkill(citizenid, skillName, skill)
end
return skill
end
exports('AddSkillXP', AddSkillXP)
Skill Trees and Unlockable Abilities
Skill trees add a strategic layer on top of raw leveling. Instead of every player at level 20 in mechanics being identical, skill trees let them specialize. One mechanic might invest points in engine performance, unlocking the ability to add turbo kits, while another specializes in bodywork, gaining access to exclusive paint jobs and liveries. Each level grants one skill point that can be spent in a branching tree. The tree structure uses a simple parent-child relationship where unlocking a child node requires both the parent to be unlocked and a minimum skill level. This prevents players from rushing to the most powerful abilities without building foundational competence.
-- shared/skill_trees.lua
SkillTrees = {
mechanic = {
{ id = 'basic_repair', label = 'Basic Repair', reqLevel = 1, parent = nil,
effect = { type = 'speed', value = 1.0 }, desc = 'Standard repair speed' },
{ id = 'fast_repair', label = 'Quick Hands', reqLevel = 5, parent = 'basic_repair',
effect = { type = 'speed', value = 1.3 }, desc = '30% faster repairs' },
{ id = 'engine_diag', label = 'Engine Diagnostics', reqLevel = 10, parent = 'basic_repair',
effect = { type = 'unlock', value = 'engine_scan' }, desc = 'Scan engine health remotely' },
{ id = 'turbo_kit', label = 'Turbo Installation', reqLevel = 20, parent = 'engine_diag',
effect = { type = 'unlock', value = 'install_turbo' }, desc = 'Install turbo kits on vehicles' },
{ id = 'master_tune', label = 'Master Tuner', reqLevel = 35, parent = 'turbo_kit',
effect = { type = 'unlock', value = 'advanced_tune' }, desc = 'Access to advanced ECU tuning' },
{ id = 'body_expert', label = 'Body Expert', reqLevel = 10, parent = 'basic_repair',
effect = { type = 'unlock', value = 'custom_paint' }, desc = 'Unlock exclusive paint options' },
{ id = 'livery_master', label = 'Livery Master', reqLevel = 25, parent = 'body_expert',
effect = { type = 'unlock', value = 'custom_livery' }, desc = 'Create and apply custom liveries' },
},
}
-- server: unlock a skill tree node
function UnlockNode(citizenid, skillName, nodeId)
local tree = SkillTrees[skillName]
if not tree then return false, 'No tree for skill' end
local node = nil
for _, n in ipairs(tree) do
if n.id == nodeId then node = n break end
end
if not node then return false, 'Node not found' end
local skill = GetPlayerSkill(citizenid, skillName)
if not skill or skill.level < node.reqLevel then
return false, 'Level too low'
end
if node.parent then
local parentUnlocked = IsNodeUnlocked(citizenid, skillName, node.parent)
if not parentUnlocked then return false, 'Parent node locked' end
end
MySQL.insert('INSERT INTO player_skill_nodes (citizenid, skill_name, node_id) VALUES (?, ?, ?)',
{citizenid, skillName, nodeId})
return true
end
Reputation Tiers and Faction Standing
Reputation tracks how organizations and factions perceive the player, separate from raw skill. A player might be an expert driver but have terrible standing with the police faction because of their criminal activities. Reputation is organized into tiers ranging from hostile through neutral to revered, with each tier unlocking different dialogue options, mission access, and vendor prices. Gaining reputation with one faction can reduce it with opposing factions, creating meaningful trade-offs. For example, completing drug deliveries increases cartel reputation but decreases police reputation. This system encourages players to make choices that define their character rather than maxing out every faction simultaneously.
-- shared/reputation_config.lua
ReputationConfig = {
tiers = {
{ name = 'hostile', minRep = -1000, color = '#ef4444' },
{ name = 'unfriendly', minRep = -500, color = '#f97316' },
{ name = 'neutral', minRep = 0, color = '#94a3b8' },
{ name = 'friendly', minRep = 500, color = '#22c55e' },
{ name = 'honored', minRep = 1500, color = '#3b82f6' },
{ name = 'revered', minRep = 3000, color = '#a855f7' },
},
factions = {
police = { label = 'LSPD', opposing = {'cartel', 'gang_ballas'} },
ems = { label = 'EMS', opposing = {} },
cartel = { label = 'Madrazo Cartel', opposing = {'police'} },
mechanic = { label = 'LS Customs', opposing = {} },
gang_ballas = { label = 'Ballas', opposing = {'police', 'gang_families'} },
gang_families = { label = 'Families', opposing = {'gang_ballas'} },
},
opposingPenalty = 0.5, -- lose 50% of gained rep from opposing factions
}
-- server: modify reputation with faction cascading
function ModifyReputation(citizenid, faction, amount)
local config = ReputationConfig.factions[faction]
if not config then return end
-- Apply to primary faction
AdjustRep(citizenid, faction, amount)
-- Penalize opposing factions
if amount > 0 then
for _, opposing in ipairs(config.opposing) do
local penalty = math.floor(amount * ReputationConfig.opposingPenalty)
AdjustRep(citizenid, opposing, -penalty)
end
end
end
The Prestige System
Once a player reaches maximum level in a skill, they need a reason to keep engaging with that activity. The prestige system provides that by allowing players to reset a skill back to level 1 in exchange for permanent bonuses: a cosmetic prestige badge displayed next to their name, a 5% XP boost per prestige level on that skill, and access to prestige-exclusive unlockables like unique vehicle modifications or rare crafting recipes. The prestige counter is unlimited, but each successive prestige takes longer because the XP boost makes earlier levels trivial while the curve still catches up at higher levels. Display the prestige count as roman numerals or stars next to the skill name in the UI to give a clear visual indicator of a player's dedication.
-- server: prestige a maxed skill
function PrestigeSkill(citizenid, skillName)
local skill = playerCache[citizenid] and playerCache[citizenid][skillName]
if not skill then return false, 'Skill not found' end
if skill.level < SkillsConfig.MaxLevel then return false, 'Not max level' end
-- Reset level and XP, increment prestige
skill.level = 1
skill.xp = 0
skill.prestige = skill.prestige + 1
skill.dirty = true
-- Save immediately
SaveSkill(citizenid, skillName, skill)
-- Grant prestige reward
local src = GetPlayerByCitizenId(citizenid)
if src then
TriggerClientEvent('skills:prestige', src, skillName, skill.prestige)
-- Unlock prestige-specific items
if skill.prestige == 1 then
exports.ox_inventory:AddItem(src, 'prestige_badge_'..skillName, 1)
elseif skill.prestige == 5 then
exports.ox_inventory:AddItem(src, 'gold_tool_'..skillName, 1)
end
end
return true, skill.prestige
end
RegisterNetEvent('skills:requestPrestige', function(skillName)
local src = source
local citizenid = GetCitizenId(src)
local success, result = PrestigeSkill(citizenid, skillName)
if success then
lib.notify(src, { title = 'Prestige!', description = ('Prestige %d achieved for %s'):format(result, skillName), type = 'success' })
else
lib.notify(src, { title = 'Cannot Prestige', description = result, type = 'error' })
end
end)
Client-Side UI and Passive XP Tracking
The client handles passive XP tracking for activities like driving distance and sprinting, sending periodic updates to the server. A common mistake is sending an event on every meter traveled, which floods the network. Instead, accumulate distance in a local variable and send a batch update every 30 seconds. For the UI, build a skills panel accessible through a command or keybind that shows all skills with their current level, XP progress bar, prestige count, and the skill tree. Use NUI with a modern framework like React or Vue for the tree visualization, showing locked nodes as grayed out and unlocked nodes with a glow effect. The progress bar should animate smoothly when XP is gained, giving satisfying visual feedback that reinforces the progression loop and keeps players motivated to continue developing their character.