DirkScripts
lib.skill
Levels without inventing your own. One curve, one storage rule, one level-up
event — and the same arithmetic in Lua, in the settings panel and in
dirk-cfx-react, so a gate and the bar the player is looking at cannot
disagree.
ℹ️ A level is never stored. Only XP is. Level is worked out from it every time it is asked for, which is what lets an admin retune the curve — or change its shape entirely — without taking anything away from anyone.
#One skill, or one skill each
Some skills are single: there is one fishing level and you either have it or you do not. Others are instanced: the same skill, the same curve, held separately against each of several things.
lualib.skill.register('fishing', { baseXP = 83, maxLevel = 99 }) lib.skill.register('yardRep', { baseXP = 40, maxLevel = 10, instanced = true })
A scrapyard's reputation is the second kind. It is not one skill called "yard rep" — it is one definition with an instance per yard, because you are somebody to one man and nobody to the next. Every call on an instanced skill names the instance alongside the player.
#Registering
Call register once, from a shared file, so both sides agree. Call update
from a scriptConfig watcher for every later edit.
lualib.skill.register('yardRep', { instanced = true }) lib.scriptConfig.on('scrapyards', function(section) lib.skill.update('yardRep', section?.rep) end)
| Option | Meaning |
|---|---|
baseLevel | Where everyone starts. Default 1. |
maxLevel | The ceiling. Default 99. |
baseXP | What level 2 costs. Every later level scales off it. Default 83. |
modifier | Stretches the whole curve. Higher is slower. Default 1. |
curve | The shape: runescape (default), linear, quadratic, softcap. |
instanced | Held per-something rather than once per player. |
metaKey | Override the storage key. See below. |
#The shapes
curve | Feels like |
|---|---|
runescape | Early levels fly past, the last few are a project. What every existing dirk level was earned against, so it is the default. |
linear | Every level costs the same. Predictable, and flat at the top. |
quadratic | Cost grows with the square. A real climb without a cliff. |
softcap | Steep early, easing near the ceiling. For a ladder meant to be finished. |
Switching shape takes nobody's XP. Everyone is simply re-read against the new ladder.
#The storage key
XP lives in framework player metadata under a key namespaced by the owning
resource — dirk_projectCars_yardRep. A stock QBX install already ships
craftingrep, jobrep and dealerrep, so an unprefixed name is a collision
with somebody else's script and the loser fails silently.
metaKey overrides it, and exists for exactly one reason: a skill that already
has live data. dirk_fishing keeps fishingXp because every level every player
has earned lives there, and renaming it would orphan the lot.
#Server
lualib.skill.get(src, id, key?) --> number lib.skill.all(src, id) --> { [instance] = xp } (instanced only) lib.skill.set(src, id, amount, key?) --> number lib.skill.add(src, id, amount, key?) --> xp, levelled, level lib.skill.remove(src, id, amount, key?) --> xp, levelled, level lib.skill.atLeast(src, id, level, key?) --> boolean lib.skill.progressFor(src, id, key?) --> table (see Progress) lib.skill.syncAll(src) -- push everything, on spawn
add is the write path. It clamps, stores, pushes the new value to that
player's client, and fires a level-up event if a threshold was crossed. Never
write the metadata yourself — three sites in fishing used to, and each one left
the player's own game showing a stale level until they relogged.
lualocal xp, levelled, level = lib.skill.add(src, 'yardRep', 25, yardId) if levelled then -- your script decides what a level-up looks like; dirk_lib only says one happened end
#Gating
luaif not lib.skill.atLeast(src, 'fishing', rod.lvlRequired) then return -- refuse end
The client has the same call for showing things — greying a locked option, ordering a list. Never for deciding one: the server re-checks everything, because the client's number was handed to it.
#Client
lualib.skill.get(id, key?) --> number lib.skill.all(id) --> { [instance] = xp } lib.skill.levelOf(id, key?) --> number lib.skill.progressFor(id, key?) --> table lib.skill.atLeast(id, level, key?) --> boolean
Pushed, never fetched. Framework client-side metadata does not reliably refresh, so the server tells the client on every write and this holds the answer. Opening a UI that shows a level costs no round trip.
#Progress
progress(id, xp) — and its progressFor variants — return everything a bar
needs, with the same field names createSkill uses in dirk-cfx-react, so
a payload built in Lua drops straight into LevelPanel or LevelBanner with
nothing in between to go stale.
lua{ xp = 4200, level = 24, currentLevelXp = 4139, nextLevelXp = 4470, xpToNext = 270, progress = 18.4, maxed = false, }
#Events
| Event | Fired |
|---|---|
dirk_lib:levelUp | server and client, when add crosses a threshold. (src, id, key, level, wasLevel) on the server; (id, key, level, wasLevel) on the client. |
dirk_lib:levelSync | client, on every write. Handled internally. |
dirk_lib:levelSyncAll | client, from syncAll. Handled internally. |
#In the settings panel
Mark the settings block with x-skill and it draws the curve
it describes — the shape of the climb, what real levels cost, the total to max,
and how that compares with the default pace, all recomputed as you drag.
json"rep": { "type": "object", "x-skill": "yardRep", "properties": { "curve": { "type": "string", "x-enum": ["runescape", "linear", "quadratic", "softcap"] }, "baseLevel": { "type": "number", "default": 1 }, "maxLevel": { "type": "number", "default": 10 }, "baseXP": { "type": "number", "default": 40 }, "modifier": { "type": "number", "default": 1, "x-control": "multiplier" } } }
Named rather than flagged, because a script can have several.
Last updated on 9 September 2026
