Developer

10 min readUpdated Today

Overview

ML Skills exposes 30+ server exports plus three client open-hook overrides. Other resources never need to read the database directly. Every state mutation and every query has a matching export.

All exports are PascalCase and live under exports.ml_skills:<Name>(...). Names are case-sensitive.

Argument order

Category-scoped server exports take the category UID first and the player id last: AddXp(categoryUid, amount, source). Passing the player id first sends the id into the category parameter, the category into the amount, and the call is rejected without an error. Exports that are not category-scoped (ResetSkills, FullWipePlayer, GetUnlockedSkills, GetPlayerGlobalStats, GetPlayerAchievements, ClaimAchievement) take the player id first.

Server Exports

XP

  • AddXp(categoryUid, amount, source): Adds XP to a player. Returns true on success, false when the amount is invalid, the player is not cached, the category does not exist, or the daily cap absorbed the grant
  • RemoveXp(categoryUid, amount, source): Subtracts XP. Returns true, removedAmount
  • GetPlayerXp(categoryUid, source): Returns the current-level XP (progress toward the next level, not cumulative)
  • GetPlayerTotalXp(categoryUid, source): Returns the lifetime XP earned in the category

Amounts must be positive integers up to 999999. A cached player always has at least level 1 in every configured category, so a return of 0 from a getter means the player is not loaded yet.

Points

  • AddPoints(categoryUid, amount, source): Grants unspent skill points
  • RemovePoints(categoryUid, amount, source): Subtracts skill points, floored at 0
  • GetPlayerPoints(categoryUid, source): Returns the unspent skill points

Levels

  • GetPlayerLevel(categoryUid?, source): Returns the current level. Pass nil as the category to read the first populated tree
  • GetSkillLevel(categoryUid?, source): Alias of GetPlayerLevel, kept for readability in integration code
  • SetLevel(categoryUid, level, source): Sets the level to an exact value, clamped between 1 and the category maxLevel, and recalculates the point pool. Bypasses the daily XP cap
  • GetPlayerGlobalStats(source): Returns { totalXp, totalLevel, usedPoints, unlockedSkills } aggregated across every category

Skill State

  • UnlockSkill(categoryUid, skillUid, source): Unlocks the skill if the requirements are met. Returns true on success or false, reason on failure. Gated by Config.EnableUnlockSkillExport
  • ClaimSkillReward(categoryUid, skillUid, source): Claims a pending reward attached to a previously unlocked skill
  • LockSkill(categoryUid, skillUid, source): Locks a previously unlocked skill and refunds the point
  • HasUnlockedSkill(categoryUid?, skillUid, source): Returns true when the skill is in the player's unlocked list. The category is optional here, a missing one falls back to a scan by UID across every tree
  • GetUnlockedSkills(source): Returns an array of every unlocked skill across every category
  • GetSkillEffect(categoryUid?, skillUid): Returns the effect value of the skill definition (e.g. 10 for a +10% health perk). Reads the tree, so it takes no player id
  • FindSkillCategory(skillUid): Resolves a skill UID to the category it belongs to, or nil

Tree & Categories

  • GetConfig(): Returns { Categories, Trees }, the runtime tables with the database values already merged over shared/config.lua
  • GetSkillTrees(): Returns the full tree definitions, keyed by category UID
  • SaveSkillTree(categoryUid, treeArray, source?, suppressBroadcast?): Persists a tree, bumps the version counter and broadcasts the update. Returns the new version. source is the admin player id recorded in the audit trail
  • GetTotalCategoryBonus(categoryUid, source): Sums the effect of every unlocked skill in the category. This is how a consumer turns a tree into a single gameplay multiplier
Categories live in the database

Categories created from the admin panel exist only in the database, not in shared/config.lua. GetConfig().Categories returns the merged runtime map, so it is the correct source when validating a category UID from another resource.

Lock Helpers

  • GetSkillDef(categoryUid?, skillUid): Returns the full skill definition from the tree
  • IsSkillLocked(categoryUid, skillUid, source): Returns locked, reason, data
  • GetSkillLockReason(categoryUid, skillUid, source): Returns { locked, reason, data, message }, where message is the localized text and nil when the reason has no message
  • GetBlockedSkills(categoryUid, skillUid): Returns the skills that are mutually exclusive with this one, as an array of { category, uid, name }

Reset & Wipe

  • ResetSkills(source, isAdmin?): Resets every category for a player and refunds all spent points. Returns true, refundedPoints. Blocked unless Config.AllowReset is on or isAdmin is true
  • ResetCategory(categoryUid, source): Resets a single category. Requires Config.AllowReset
  • FullWipePlayer(source): Wipes every category, every unlock, every achievement. Irreversible

Boost, Prestige, Leaderboard

  • AddXpBoost(categoryUid, multiplier, durationMs, source): Applies a temporary XP multiplier (1.5 = +50%). The multiplier must be between 0 and 10 and the duration must not exceed 86400000 ms (24 hours), longer values are rejected
  • PrestigeCategory(categoryUid, source): Resets the category at max level and increments the prestige counter, granting the configured XP bonus. Returns true, newPrestigeLevel. Requires Config.PrestigeEnabled
  • GetLeaderboard(categoryUid, limit?): Returns the top players in the category, ordered by prestige, then level, then XP. limit defaults to 10

Achievements

  • GetPlayerAchievements(source): Returns the player's achievement state, keyed by achievement id, with completed and claimed flags
  • ClaimAchievement(source, achievementId): Claims an unlocked achievement and pays out its rewards
  • GetAchievementDefinitions(): Returns the seeded and editor-saved achievement definitions

Client Exports

Called on the client (for example from open/effects.lua or another client script). They read the local player's state.

  • GetActiveEffect(skillType): Returns the summed effect value of an unlocked skill type for the local player, or 0. This is the integration point other scripts read, ml_recoil folds the betterAccuracy effect into its recoil multiplier this way
  • HasUnlockedSkill(categoryUid, skillUid): Client-side unlock check. The category is required here, unlike the server export
  • GetSkillEffect(categoryUid, skillUid): Client-side effect value for the local player
  • GetTotalCategoryBonus(categoryUid): Client-side sum of the effects unlocked in the category
  • GetPlayerData(): Returns a copy of the local player's data, or nil while it has not arrived yet. GetPlayerData().categories[categoryUid].level is the client-side level read
  • RequestXp(categoryUid, amount, reason): Asks the server to award a whitelisted XP listener. See below
  • OpenSkillTree(categoryUid?): Opens the skill UI, optionally focused on a category
  • CloseSkillTree(): Closes the skill UI
  • SetXpEarningStatus(enabled) / GetXpEarningStatus(): Pauses and resumes passive XP listeners for the local player
  • ReloadDefaultSkills(): Re-applies every unlocked skill effect
Client data arrives after the join

HasUnlockedSkill returns false, not an error, while the player data is still missing (join window, or a hot ensure ml_skills that already connected clients never receive). Probe GetPlayerData() first and treat nil as "unknown" instead of "not unlocked", otherwise a client-side gate locks players out.

XP From a Client-Side Event

Server exports cannot be called from a client script. exports.ml_skills:AddXp only exists on the server, so calling it from a client file throws.

When the action is only detectable on the client (a kill from another resource, a minigame result, a client-side event), use the client export RequestXp. The client never sends an amount that the server trusts: it sends the name of a listener, and the server resolves the category and the XP value from Config.XpListeners, then applies its own rate limits.

Declare the listener without interval and condition so it never fires on its own:

shared/config.lua
1Config.XpListeners = {
2    zombie_kill = {
3        category = 'personal',
4        xp = 2,
5        requiresMovement = false,
6        cooldown = 2000,
7    },
8}

requiresMovement = false matters here: a kill can happen while the player stands still, and without it the server drops the claim as an AFK farm. cooldown is the minimum gap between two grants of this listener, 5000 ms when not set.

Then request it from your client script:

lua
1AddEventHandler('onSomethingKilled', function(entity)
2    if GetPedSourceOfDeath(entity) == PlayerPedId() then
3        exports.ml_skills:RequestXp('personal', 2, 'zombie_kill')
4    end
5end)

The arguments are the category UID first, the XP amount second and the listener name last: RequestXp(categoryUid, amount, reason). The category and the amount must match the listener entry exactly, or the call is dropped on both sides.

Server-side the request is validated before any XP is granted:

  • the listener name must exist in Config.XpListeners
  • the category and the amount must match the configured entry
  • one grant per listener name per cooldown, taken from the listener cooldown or from its own tick rate, never below 1000 ms
  • the ped must have moved since the previous grant, unless the listener sets requiresMovement = false or Config.XpListenerMovementCheck is off
  • Config.XpListenerCapPerMinute and Config.XpListenerDailyCapPerCategory cap the total per category
Prefer the server when the event exists there

If the other resource also fires the kill or completion event server-side, award the XP there instead: exports.ml_skills:AddXp('personal', 2, src) has no cooldown and no movement check.

Events

  • ml_skills:server:categoriesChanged: Server-side TriggerEvent fired when an admin saves the category list. The argument is an array of removed category UIDs
server/main.lua
1AddEventHandler('ml_skills:server:categoriesChanged', function(removed)
2    MyCache.skills = nil
3end)

Resources that cache a category or skill picker should listen to it, otherwise a deleted category keeps showing in their admin UI until their own cache expires.

Client Hooks

The client side ships three overridable hooks in open/client.lua. Return true from OnXpGained or OnLevelUp to skip the built-in notification.

Notify

open/client.lua
1function OpenClient.Notify(message, type)
2    Bridge.Notify(message, type)
3end

Default routes through Bridge.Notify. Override to send notifications to a custom HUD framework.

OnXpGained

open/client.lua
1function OpenClient.OnXpGained(categoryUid, categoryLabel, amount, catData, xpNeeded)
2    return false
3end

Fires every time the player gains XP. catData carries the full category snapshot (level, xp, points, prestige). Return true to suppress the default XP toast.

OnLevelUp

open/client.lua
1function OpenClient.OnLevelUp(categoryUid, categoryLabel, newLevel)
2    return false
3end

Fires when the player crosses a level threshold. Return true to suppress the default level-up notification.

Skill Effect Handlers

open/effects.lua exposes the SkillHandlers table. Each entry maps a skillType (the UID with the trailing _N stripped) to a function that applies the effect.

The core re-applies every handler on resource start, on player spawn, and whenever a skill is unlocked. The total effect value passed to the handler is the sum of every unlocked skill of the same skillType.

open/effects.lua
1local function applyRunFaster(effect)
2    SetRunSprintMultiplierForPlayer(cache.playerId, 1.0 + (0.03 * effect))
3end
4SkillHandlers.runFaster  = applyRunFaster
5SkillHandlers.run_faster = applyRunFaster

Ship snake_case aliases when the tree uses snake_case UIDs so older trees keep working.

Tick-Loop Effects

Some effects need to fire on every frame (regen, low-HP damage bonus, taser resistance). Write a value into the shared ActiveEffects table and let the core tick loop consume it.

open/effects.lua
1local function applyHpRegen(effect)
2    if not ActiveEffects then ActiveEffects = {} end
3    ActiveEffects.hpRegen = effect
4end
5SkillHandlers.hpRegeneration = applyHpRegen

Built-in Handlers

The default tree ships with handlers for:

  • Movement: runFaster, swimFaster, moreStamina, moreStaminaRegen, timeUnderwater, reduceStaminaOnBike
  • Health: moreMaxHp, hpRegeneration
  • Damage: feastDamageMultiplier (fists), meleeDamageMultiplier (melee weapons), damageReducer, ignoreTazer
  • Combat: betterAccuracy (recoil shake), fasterReload (no-op until wired to a weapon framework), lowHpWeaponDamageMultiplier

Replace any handler by reassigning SkillHandlers.<key> in open/effects.lua.

Examples

lua
1-- After a delivery in your job script, server-side:
2exports.ml_skills:AddXp('personal', 50, src)
lua
1-- Client-side. 'zombie_kill' must exist in Config.XpListeners with category 'personal' and xp = 2.
2exports.ml_skills:RequestXp('personal', 2, 'zombie_kill')
lua
1-- Server-side, before exposing an advanced feature:
2if exports.ml_skills:HasUnlockedSkill('medical', 'reviveSpeed_3', src) then
3    TriggerClientEvent('myJob:showAdvancedRevive', src)
4end
lua
1-- Stack a per-skill discount in your shop, server-side:
2local bonus = exports.ml_skills:GetTotalCategoryBonus('social', src)
3local finalPrice = basePrice * (1.0 - bonus / 100)
lua
1-- Server-side, on every player join during the event.
2-- 24 hours is the maximum duration the export accepts:
3exports.ml_skills:AddXpBoost('personal', 2.0, 24 * 60 * 60 * 1000, src)
lua
1CreateThread(function()
2    while true do
3        local top = exports.ml_skills:GetLeaderboard('personal', 10)
4        TriggerClientEvent('myHud:showLeaderboard', -1, top)
5        Wait(5 * 60 * 1000)
6    end
7end)
lua
1-- ml_skills may be stopped or still booting. Gate and pcall every call:
2local function AwardSkillXp(src, category, amount)
3    if GetResourceState('ml_skills') ~= 'started' then return false end
4    local ok, granted = pcall(function()
5        return exports.ml_skills:AddXp(category, amount, src)
6    end)
7    return ok and granted or false
8end
Skills Developer — FiveM Docs | Micio Mods