Guide 2026-04-17

Building a Reputation & Skills System for FiveM

OntelMonke

OntelMonke

Admin & Developer at Agency Scripts

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.

Share this article

Ready to upgrade your server?

Check out our premium FiveM scripts in the Agency Scripts store or join our Discord community for support and updates.