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
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
ml_skills:server:categoriesChanged: Server-sideTriggerEventfired when an admin saves 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.
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
1function OpenClient.Notify(message, type)
2 Bridge.Notify(message, type)
3endDefault routes through Bridge.Notify. Override to send notifications to a custom HUD framework.
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