Developer
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.
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. Returnstrueon success,falsewhen the amount is invalid, the player is not cached, the category does not exist, or the daily cap absorbed the grantRemoveXp(categoryUid, amount, source): Subtracts XP. Returnstrue, removedAmountGetPlayerXp(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 pointsRemovePoints(categoryUid, amount, source): Subtracts skill points, floored at 0GetPlayerPoints(categoryUid, source): Returns the unspent skill points
Levels
GetPlayerLevel(categoryUid?, source): Returns the current level. Passnilas the category to read the first populated treeGetSkillLevel(categoryUid?, source): Alias ofGetPlayerLevel, kept for readability in integration codeSetLevel(categoryUid, level, source): Sets the level to an exact value, clamped between 1 and the categorymaxLevel, and recalculates the point pool. Bypasses the daily XP capGetPlayerGlobalStats(source): Returns{ totalXp, totalLevel, usedPoints, unlockedSkills }aggregated across every category
Skill State
UnlockSkill(categoryUid, skillUid, source): Unlocks the skill if the requirements are met. Returnstrueon success orfalse, reasonon failure. Gated byConfig.EnableUnlockSkillExportClaimSkillReward(categoryUid, skillUid, source): Claims a pending reward attached to a previously unlocked skillLockSkill(categoryUid, skillUid, source): Locks a previously unlocked skill and refunds the pointHasUnlockedSkill(categoryUid?, skillUid, source): Returnstruewhen 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 treeGetUnlockedSkills(source): Returns an array of every unlocked skill across every categoryGetSkillEffect(categoryUid?, skillUid): Returns theeffectvalue of the skill definition (e.g.10for a+10%health perk). Reads the tree, so it takes no player idFindSkillCategory(skillUid): Resolves a skill UID to the category it belongs to, ornil
Tree & Categories
GetConfig(): Returns{ Categories, Trees }, the runtime tables with the database values already merged overshared/config.luaGetSkillTrees(): Returns the full tree definitions, keyed by category UIDSaveSkillTree(categoryUid, treeArray, source?, suppressBroadcast?): Persists a tree, bumps the version counter and broadcasts the update. Returns the new version.sourceis the admin player id recorded in the audit trailGetTotalCategoryBonus(categoryUid, source): Sums theeffectof every unlocked skill in the category. This is how a consumer turns a tree into a single gameplay multiplier
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 treeIsSkillLocked(categoryUid, skillUid, source): Returnslocked, reason, dataGetSkillLockReason(categoryUid, skillUid, source): Returns{ locked, reason, data, message }, wheremessageis the localized text andnilwhen the reason has no messageGetBlockedSkills(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. Returnstrue, refundedPoints. Blocked unlessConfig.AllowResetis on orisAdministrueResetCategory(categoryUid, source): Resets a single category. RequiresConfig.AllowResetFullWipePlayer(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 rejectedPrestigeCategory(categoryUid, source): Resets the category at max level and increments the prestige counter, granting the configured XP bonus. Returnstrue, newPrestigeLevel. RequiresConfig.PrestigeEnabledGetLeaderboard(categoryUid, limit?): Returns the top players in the category, ordered by prestige, then level, then XP.limitdefaults to 10
Achievements
GetPlayerAchievements(source): Returns the player's achievement state, keyed by achievement id, withcompletedandclaimedflagsClaimAchievement(source, achievementId): Claims an unlocked achievement and pays out its rewardsGetAchievementDefinitions(): 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, or0. This is the integration point other scripts read, ml_recoil folds thebetterAccuracyeffect into its recoil multiplier this wayHasUnlockedSkill(categoryUid, skillUid): Client-side unlock check. The category is required here, unlike the server exportGetSkillEffect(categoryUid, skillUid): Client-side effect value for the local playerGetTotalCategoryBonus(categoryUid): Client-side sum of the effects unlocked in the categoryGetPlayerData(): Returns a copy of the local player's data, ornilwhile it has not arrived yet.GetPlayerData().categories[categoryUid].levelis the client-side level readRequestXp(categoryUid, amount, reason): Asks the server to award a whitelisted XP listener. See belowOpenSkillTree(categoryUid?): Opens the skill UI, optionally focused on a categoryCloseSkillTree(): Closes the skill UISetXpEarningStatus(enabled)/GetXpEarningStatus(): Pauses and resumes passive XP listeners for the local playerReloadDefaultSkills(): Re-applies every unlocked skill effect and clears the ones the player no longer owns. Returnstrue
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 deletes the perk the player paid for, with no error anywhere. End the effect with ReloadDefaultSkills() instead, behind a GetResourceState('ml_skills') == 'started' check so the script still runs standalone.
1SetTimeout(durationMs, function()
2 SetRunSprintMultiplierForPlayer(cache.playerId, 1.0)
3 if GetResourceState('ml_skills') == 'started' then
4 exports.ml_skills:ReloadDefaultSkills()
5 end
6end)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:
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:
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
cooldownor from its own tick rate, never below 1000 ms - the ped must have moved since the previous grant, unless the listener sets
requiresMovement = falseorConfig.XpListenerMovementCheckis off Config.XpListenerCapPerMinuteandConfig.XpListenerDailyCapPerCategorycap the total per category
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.
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 hotensure ml_skillsml_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 entryml_skills:server:skillUnlocked(source, categoryUid, skillUid): A skill was unlocked, from the player UI, from the admin panel, or from theUnlockSkillexportml_skills:server:skillLocked(source, categoryUid, skillUid): A single skill was removed by theLockSkillexport and its cost refundedml_skills:server:skillsReset(source, categoryUid?): Several unlocks were removed at once.categoryUidis the affected category for a category reset or a prestige, andnilwhen 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
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.
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.
1local UnlockedCache = {}
2
3local function CachePlayer(src)
4 local unlocked = {}
5 for _, skill in ipairs(exports.ml_skills:GetUnlockedSkills(src)) do
6 unlocked[skill.category .. ':' .. skill.uid] = true
7 end
8 UnlockedCache[src] = unlocked
9end
10
11AddEventHandler('ml_skills:server:playerLoaded', CachePlayer)
12AddEventHandler('ml_skills:server:skillsReset', CachePlayer)
13
14AddEventHandler('ml_skills:server:skillUnlocked', function(src, categoryUid, skillUid)
15 local unlocked = UnlockedCache[src]
16 if unlocked then unlocked[categoryUid .. ':' .. skillUid] = true end
17end)
18
19AddEventHandler('ml_skills:server:skillLocked', function(src, categoryUid, skillUid)
20 local unlocked = UnlockedCache[src]
21 if unlocked then unlocked[categoryUid .. ':' .. skillUid] = nil end
22end)
23
24AddEventHandler('ml_skills:server:playerUnloaded', function(src)
25 UnlockedCache[src] = nil
26end)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.stateisclaimed, orpendingwhen 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.
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
1function OpenClient.OnXpGained(categoryUid, categoryLabel, amount, catData, xpNeeded)
2 return false
3endFires 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
1function OpenClient.OnLevelUp(categoryUid, categoryLabel, newLevel)
2 return false
3endFires 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.
1local function applyRunFaster(effect)
2 SetRunSprintMultiplierForPlayer(cache.playerId, 1.0 + (0.03 * effect))
3end
4SkillHandlers.runFaster = applyRunFaster
5SkillHandlers.run_faster = applyRunFasterShip 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.
1local function applyHpRegen(effect)
2 if not ActiveEffects then ActiveEffects = {} end
3 ActiveEffects.hpRegen = effect
4end
5SkillHandlers.hpRegeneration = applyHpRegenBuilt-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
1-- After a delivery in your job script, server-side:
2exports.ml_skills:AddXp('personal', 50, src)1-- Client-side. 'zombie_kill' must exist in Config.XpListeners with category 'personal' and xp = 2.
2exports.ml_skills:RequestXp('personal', 2, 'zombie_kill')1-- Server-side, before exposing an advanced feature:
2if exports.ml_skills:HasUnlockedSkill('medical', 'reviveSpeed_3', src) then
3 TriggerClientEvent('myJob:showAdvancedRevive', src)
4end1-- 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)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)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)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