Skip to content

Keeping a character's stats, skills and perks

Everything the game’s character screen shows is the game mode’s: the stats (strength, agility, vitality, speech, the story’s progress and the Blacksmith’s prestige), the skills, the experience gathered toward each next level, the perk points not spent yet, the perks, and two states of the character, the nourishment and the energy. By default a server keeps none of it between visits: every visit starts with the game’s own fresh character.

A game mode can keep it. The server keeps a record of each player’s progression as their game reports it, the mode saves that record in its own database and puts it back at the next login. It needs the mode’s database. The names are in the Stats, skills, XP and perks group of the server API. The examples are Lua; a C# mode has the same calls on IServerApi.

GetPlayerProgress(pid) answers the record in one table:

local p = GetPlayerProgress(pid)
-- p.stats.strength { level = 12, xp = 40.5, next = 500, points = 1 }
-- p.skills.weapon_sword { level = 8, xp = 0, next = 290, points = 2 }
-- p.stats.mainlevel { level = 9, xp = 0, next = 0, points = 3 } - the main level, and the pool of points of no stat or skill
-- p.perks { "2993585c-40c9-42e3-ac45-b837f3bc50f7", ... } - the perks the character holds, by ID
-- p.nourishment 98.2 - nil until the player's game has reported
-- p.energy 97.0
Part What it is
a stat or skill level from 0 to 30, xp gathered toward the next level, next what the next level takes in all, points not spent yet (the names)
stats.mainlevel the main level - the game works it out from the other levels, so it is read only; its points are the pool that belongs to no stat or skill
skills.fencing Warfare - the game works it out from the weapon skills, so only its points are the mode’s
perks the perks the character holds, by ID (the perks)
nourishment, energy how fed and how rested the character is

The player’s game reports its progression a few seconds after each spawn, the moment a level, a perk or perk points change, within seconds for experience and the two states, and the whole of it again every minute. A stat or skill the server has not heard of yet reads as level 0, so read the record after the game has reported, not before.

Nobody gains experience unless the mode says so. The game awards experience for a fight, a ride, a book or a drink; the server’s rate multiplies every award, and the rate is 0 by default. SetXPRate(rate) sets it for everyone - 1 is the game’s own, 2 twice as much, up to 100 - and SetPlayerXPRate(pid, rate) for one player (nil gives them back the server-wide rate).

function OnGameModeInit()
SetXPRate(1) -- the game's own experience counts
end

A mode that wants a word on every award defines OnPlayerGainXP(pid, kind, name, xp). kind is "stat" or "skill", xp the award already times the rate. Return nothing to give it, false to refuse it, or a number to give that much instead:

local apprentices = {} -- the mode's own: pid -> true for the players learning at the guild
function OnPlayerGainXP(pid, kind, name, xp)
if name == "drinking" then return false end -- no drinking experience on this server
if apprentices[pid] and kind == "skill" then return xp * 2 end
end

A rate of 0 gives nothing and asks nothing, so a mode that defines the callback sets a rate too. Experience the mode hands out itself goes the game’s way, with the level-up notice and the perk points: AddPlayerStatXP(pid, stat, xp) and AddPlayerSkillXP(pid, skill, xp). A level a player earns reaches OnPlayerLevelUp(pid, kind, name, level) - the main level too, as the stat mainlevel.

OnPlayerProgressChange(pid, levels) says the record changed. levels is true when a level, a perk or perk points changed - save now, it is the character - and false when only experience or the two states moved, which a timer can save. It also runs once for the first report after the player joins. The mode’s own sets do not call it.

Never write before the restore has gone out. Until the saved progression is put back (section 4) the record holds the game’s fresh character, and writing that over the stored one would lose it. A character with nothing saved yet starts from its game’s own first report.

The mode below keeps one row per stat and skill and one per perk. It keys a character by name for brevity (a mode with accounts uses its account’s id) and uses the database calls of the databases guide:

local db = GetDatabase()
-- pid -> { who = the character, ready = may be written, fresh = nothing saved yet, dirty = moved since the last save,
-- stored = { ["stat:strength"] = "12:40.5:1", perks = "id,id,..." } - what the tables hold now }
local players = {}
local function key(v) return string.format("%d:%.1f:%d", v.level, v.xp, v.points) end
function OnGameModeInit()
SetXPRate(1)
if not db then Log("no database: progression is not kept") return end
db:ExecuteSync("CREATE TABLE IF NOT EXISTS player_progress (who VARCHAR(24) NOT NULL, kind VARCHAR(8) NOT NULL, " ..
"name VARCHAR(32) NOT NULL, level SMALLINT NOT NULL, xp DOUBLE PRECISION NOT NULL, points SMALLINT NOT NULL, " ..
"PRIMARY KEY (who, kind, name))")
db:ExecuteSync("CREATE TABLE IF NOT EXISTS player_perks (who VARCHAR(24) NOT NULL, perk VARCHAR(36) NOT NULL, " ..
"PRIMARY KEY (who, perk))")
SetTimer(saveAllProgress, 60000, true) -- every minute, for whoever gained experience since
end
function OnPlayerConnect(pid)
players[pid] = { stored = {} }
restoreProgress(pid, GetPlayerName(pid):lower()) -- section 4; with passwords, once the password is right
end
function OnPlayerProgressChange(pid, levels)
local p = players[pid]
if not p then return end
if p.fresh then p.ready = true end -- a new character: its game's first report is where it starts
if levels then saveProgress(pid) else p.dirty = true end
end
function OnPlayerDisconnect(pid, reason)
saveProgress(pid)
players[pid] = nil
end
function OnGameModeExit() -- the server stops, or the mode is reloaded
for pid in pairs(players) do saveProgress(pid) end
end
function saveProgress(pid)
local p = players[pid]
local now = p and p.who and p.ready and GetPlayerProgress(pid)
if not now then return end -- not back yet: nothing is written
p.dirty = nil
local batch = {}
for _, group in ipairs({ { "stat", now.stats }, { "skill", now.skills } }) do
for name, v in pairs(group[2]) do
local id = group[1] .. ":" .. name
if p.stored[id] ~= key(v) then -- only what differs from the last save
p.stored[id] = key(v)
batch[#batch + 1] = { "DELETE FROM player_progress WHERE who = @w AND kind = @k AND name = @n",
{ w = p.who, k = group[1], n = name } }
batch[#batch + 1] = { "INSERT INTO player_progress (who, kind, name, level, xp, points) VALUES (@w, @k, @n, @l, @x, @p)",
{ w = p.who, k = group[1], n = name, l = v.level, x = v.xp, p = v.points } }
end
end
end
local perks = table.concat(now.perks, ",")
if perks ~= p.stored.perks then
p.stored.perks = perks
batch[#batch + 1] = { "DELETE FROM player_perks WHERE who = @w", { w = p.who } }
for _, perk in ipairs(now.perks) do
batch[#batch + 1] = { "INSERT INTO player_perks (who, perk) VALUES (@w, @g)", { w = p.who, g = perk } }
end
end
if #batch == 0 then return end
db:Batch(batch, function(ok, err)
if ok then return end
Log("progression not saved: " .. tostring(err))
p.stored, p.dirty = {}, true -- the next round writes it all again
end)
end
function saveAllProgress()
for pid, p in pairs(players) do
if p.dirty then saveProgress(pid) end
end
end

SetPlayerProgress(pid, progress) takes a table of the shape GetPlayerProgress gives, and every part is optional. It is a whole restore and exact:

  • every stat and skill listed is set as it is - its level, its experience and its perk points (none counts as 0), lowering too, and without the game’s level-up notice. An entry without a level is skipped;
  • stats.mainlevel puts back only the pool of perk points, skills.fencing only Warfare’s points: the game works both levels out from the others;
  • perks, when given, makes the character’s perks exactly that list;
  • nourishment and energy are set when given.

The server takes it at once (GetPlayerProgress right afterwards answers it) and sends it to the player’s game. A restore made before the player is in the world waits for their spawn, and a report their game sent before the restore landed never overwrites it.

-- when the mode knows which character this is: OnPlayerConnect on a server without passwords, else once the password is right
function restoreProgress(pid, who)
local p = players[pid]
if not (db and p) then return end
p.who = who
db:Query("SELECT kind, name, level, xp, points FROM player_progress WHERE who = @w", { w = who }, function(rows, err)
if err then Log("progression not read: " .. err) return end -- nothing is written this visit
if players[pid] ~= p then return end -- they left meanwhile
if #rows == 0 then
p.fresh = true -- nothing saved: the game's own start is the first save
return
end
local progress = { stats = {}, skills = {} }
for _, r in ipairs(rows) do
local v = { level = r.level, xp = r.xp, points = r.points }
progress[r.kind == "stat" and "stats" or "skills"][r.name] = v
p.stored[r.kind .. ":" .. r.name] = key(v)
end
db:Query("SELECT perk FROM player_perks WHERE who = @w", { w = who }, function(perkRows, perkErr)
if perkErr or players[pid] ~= p then return end
progress.perks = {}
for _, r in ipairs(perkRows) do progress.perks[#progress.perks + 1] = r.perk end
table.sort(progress.perks)
p.stored.perks = table.concat(progress.perks, ",")
SetPlayerProgress(pid, progress)
p.ready = true -- from here on the record is the saved character
end)
end)
end

The perk list replaces the character’s perks. Every player is given the perfect block, the riposte and the master strikes when they first spawn, and the list GetPlayerProgress gives holds them. A list the mode writes itself takes them away unless it names them too. The perks a fresh character holds beyond the list are taken away only once the server has heard which perks they hold - a few seconds after the spawn. A restore made before that adds the missing ones; a second SetPlayerProgress after the first OnPlayerProgressChange makes the list exact.

Call What it does
SetPlayerStat(pid, stat, level [, xp]), SetPlayerSkill(pid, skill, level [, xp]) the level and the experience exactly, lowering too, no notice; the perk points stay
AddPlayerStatXP, AddPlayerSkillXP experience the game’s way: the level-up notice and the perk points
SetPlayerPerkPoints(pid, name, points) a stat’s or a skill’s unspent points, or the main level’s pool (mainlevel)
AddPlayerPerk, RemovePlayerPerk, HasPlayerPerk a perk by ID, or by name once the game’s tables are exported; GetPerkName answers the name
SetPlayerNourishment, SetPlayerEnergy how fed and how rested, up to the maximum the game reported

A stat or skill a mode raises shows on the other players’ screens too: the character’s body there takes the same level, so the game treats it the same way. A bow’s draw, for one, holds or drops by the archer’s strength against the bow on every screen.

A horse has strength, agility, vitality and courage of its own: SetHorseStat(id, stat, level) sets one from 0 to 30 on every copy of the horse - its rider’s and everyone’s who sees it - and carry capacity, speed, stamina and courage follow at once. GetHorseStat and GetHorseStats read them, 0 or an empty table for a horse never set (it has the game’s own). The stats live as long as the horse: a mode that keeps its players’ horses saves them with the horse’s row and sets them again when it makes the horse anew.

basicrp.lua in the server folder is the worked example: it saves each character’s stats, skills, experience, perk points, perks, nourishment and energy (player_progress, player_perks and two columns of its players table), puts them back at the login, and keeps the stats an admin gave a horse (horse_stats). Its PROGRESS table holds the rate (xp_rate = 1). Everyone has /skills [name]; admins have /setlevel, /givexp, /perk, /perkpoints, /xprate and /horsestat. freeroam.lua gives the game’s own experience, /skills and the admin commands but /horsestat, and keeps nothing; duel_arena.lua keeps every duelist equal with a rate of 0.

  • The rate is 0 by default. A mode of your own that never calls SetXPRate has nobody gaining experience.
  • [spawn] stat_level in server.toml raises the four core stats at every spawn and never lowers them. A mode that keeps its players’ levels leaves it at 0.
  • The progression is checked. The record is what the player’s game reports, and the server’s audit judges it against what it can explain. With [audit] mode = "enforce" a rise nothing explains is put back. A server whose story quests award experience vouches for it in OnPlayerProgressViolation. The server ships with [audit] mode = "observe", which judges and logs but puts nothing back: a value a player’s game forges would be the one the record holds and your mode saves. A mode that keeps characters should not rely on the record until the server owner runs enforce.
  • Perk names need the tables. Perk IDs work on every server; names (AddPlayerPerk(pid, "Iron Grip")) need the exported tables of this version.