# Developer > Developer API reference for ML Skills Category: SKILLS · Source: https://miciomods.it/docs/ml-skills-developer · Last updated: 2026-09-26 ## Overview Use the server and client exports below to read skill data, award XP and manage unlocks. The hooks in open/client.lua and open/effects.lua let you change notifications and skill effects. All exports are PascalCase and live under `exports.ml_skills:(...)`. Names are case-sensitive. > **WARNING:** 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 > **INFO:** 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 and clears the ones the player no longer owns. Returns `true` > **TIP:** Restore skill effects after temporary buffs > > Skill effects are the player's baseline, not a one-off buff. A script that raises a player-wide value for a while (a sprint drink, a diving pill, a scripted state) and then writes the vanilla constant back overwrites the effect of the unlocked skill, with no error anywhere. End the effect with `ReloadDefaultSkills()` instead, behind a `GetResourceState('ml_skills') == 'started'` check so the script still runs standalone. > > ```lua > SetTimeout(durationMs, function() > SetRunSprintMultiplierForPlayer(cache.playerId, 1.0) > if GetResourceState('ml_skills') == 'started' then > exports.ml_skills:ReloadDefaultSkills() > end > end) > ``` > **WARNING:** 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** ```lua Config.XpListeners = { zombie_kill = { category = 'personal', xp = 2, requiresMovement = false, cooldown = 2000, }, } ``` `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 AddEventHandler('onSomethingKilled', function(entity) if GetPedSourceOfDeath(entity) == PlayerPedId() then exports.ml_skills:RequestXp('personal', 2, 'zombie_kill') end end) ``` 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 > **TIP:** 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 Every `ml_skills:server:*` event is a plain server-side `TriggerEvent`, never a net event. Listen with `AddEventHandler` in one of your server files. > **WARNING:** Never use RegisterNetEvent here > > Registering these names with `RegisterNetEvent` makes them triggerable from any client, which lets a modded client fake an unlock in your resource. `AddEventHandler` alone cannot be reached from the client. ### Player State - `ml_skills:server:playerLoaded(source)`: The player's skill data finished loading and every export is safe to call for that id. Fires on join, on a character switch, and once per online player after a hot `ensure ml_skills` - `ml_skills:server:playerUnloaded(source)`: The player left that character behind and their data is no longer cached. Fires on disconnect and on a character switch, so it is the correct place to drop your own entry - `ml_skills:server:skillUnlocked(source, categoryUid, skillUid)`: A skill was unlocked, from the player UI, from the admin panel, or from the `UnlockSkill` export - `ml_skills:server:skillLocked(source, categoryUid, skillUid)`: A single skill was removed by the `LockSkill` export and its cost refunded - `ml_skills:server:skillsReset(source, categoryUid?)`: Several unlocks were removed at once. `categoryUid` is the affected category for a category reset or a prestige, and `nil` when every category was cleared by a full reset or a wipe. Re-read the player instead of patching your cache entry by entry All five fire after the change is applied, so an export called from inside the handler already returns the new state. ### Admin - `ml_skills:server:categoriesChanged(removed)`: An admin saved the category list. The argument is an array of removed category UIDs **server/main.lua** ```lua AddEventHandler('ml_skills:server:categoriesChanged', function(removed) MyCache.skills = nil end) ``` 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. ### Caching Unlocks Without a Loop The player events replace a polling loop entirely: read the full list once when the player loads, then patch the cache as the events arrive. **server/main.lua** ```lua local UnlockedCache = {} local function CachePlayer(src) local unlocked = {} for _, skill in ipairs(exports.ml_skills:GetUnlockedSkills(src)) do unlocked[skill.category .. ':' .. skill.uid] = true end UnlockedCache[src] = unlocked end AddEventHandler('ml_skills:server:playerLoaded', CachePlayer) AddEventHandler('ml_skills:server:skillsReset', CachePlayer) AddEventHandler('ml_skills:server:skillUnlocked', function(src, categoryUid, skillUid) local unlocked = UnlockedCache[src] if unlocked then unlocked[categoryUid .. ':' .. skillUid] = true end end) AddEventHandler('ml_skills:server:skillLocked', function(src, categoryUid, skillUid) local unlocked = UnlockedCache[src] if unlocked then unlocked[categoryUid .. ':' .. skillUid] = nil end end) AddEventHandler('ml_skills:server:playerUnloaded', function(src) UnlockedCache[src] = nil end) ``` `GetUnlockedSkills(source)` returns an array of `{ category, uid, rewardsClaimed }`, so a `category:uid` key is enough for an exact lookup. Skip the category in the key only if your own skill UIDs are unique across every tree. `ml_skills:server:playerLoaded` also solves the join race: `GetUnlockedSkills` returns an empty array while the player is not cached yet, so caching on your own connection event can silently store an empty set. Drop the entry on `ml_skills:server:playerUnloaded` rather than on `playerDropped`. A character switch keeps the same server id and never fires `playerDropped`, so a cache keyed on the drop would serve the previous character's unlocks to the new one. ### Client Events - `ml_skills:client:skillUnlocked(categoryUid, skillUid, state)`: Sent to the owning player when they unlock a skill. `state` is `claimed`, or `pending` when the skill carries rewards the player has not collected yet A client-side listener is fine for visual feedback, never for a gate that grants something. Client events are reachable from a modded client, so keep the authority on the server. ## Client Hooks The client side ships overridable hooks in `open/client.lua`. Return `true` from `OnXpGained` or `OnLevelUp` to skip the built-in notification. > **INFO:** Notifications > > Notifications from every ML script go through `ml_bridge`. `Config.Notify` in `ml_bridge/config.lua` selects the system: auto, ox, qb, esx, wasabi, okok, mythic, pnotify, tnotify, brutal, lation, r_notify, fl, zsx. ### OnXpGained **open/client.lua** ```lua function OpenClient.OnXpGained(categoryUid, categoryLabel, amount, catData, xpNeeded) return false end ``` 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** ```lua function OpenClient.OnLevelUp(categoryUid, categoryLabel, newLevel) return false end ``` 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** ```lua local function applyRunFaster(effect) SetRunSprintMultiplierForPlayer(cache.playerId, 1.0 + (0.03 * effect)) end SkillHandlers.runFaster = applyRunFaster SkillHandlers.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** ```lua local function applyHpRegen(effect) if not ActiveEffects then ActiveEffects = {} end ActiveEffects.hpRegen = effect end SkillHandlers.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.` in `open/effects.lua`. ## Examples **Award XP from a job resource** ```lua -- After a delivery in your job script, server-side: exports.ml_skills:AddXp('personal', 50, src) ``` **Award XP from a client-side kill event** ```lua -- Client-side. 'zombie_kill' must exist in Config.XpListeners with category 'personal' and xp = 2. exports.ml_skills:RequestXp('personal', 2, 'zombie_kill') ``` **Gate a feature behind a skill** ```lua -- Server-side, before exposing an advanced feature: if exports.ml_skills:HasUnlockedSkill('medical', 'reviveSpeed_3', src) then TriggerClientEvent('myJob:showAdvancedRevive', src) end ``` **Read a category bonus to compute a custom value** ```lua -- Stack a per-skill discount in your shop, server-side: local bonus = exports.ml_skills:GetTotalCategoryBonus('social', src) local finalPrice = basePrice * (1.0 - bonus / 100) ``` **Apply a temporary 2x XP boost** ```lua -- Server-side, on every player join during the event. -- 24 hours is the maximum duration the export accepts: exports.ml_skills:AddXpBoost('personal', 2.0, 24 * 60 * 60 * 1000, src) ``` **Push a leaderboard widget every 5 minutes** ```lua CreateThread(function() while true do local top = exports.ml_skills:GetLeaderboard('personal', 10) TriggerClientEvent('myHud:showLeaderboard', -1, top) Wait(5 * 60 * 1000) end end) ``` **Call an export safely from another resource** ```lua -- ml_skills may be stopped or still booting. Gate and pcall every call: local function AwardSkillXp(src, category, amount) if GetResourceState('ml_skills') ~= 'started' then return false end local ok, granted = pcall(function() return exports.ml_skills:AddXp(category, amount, src) end) return ok and granted or false end ```