Skip to content

Keeping what a player discovered

A character finds things as they play. The places they find - villages, towns, camps and other points of interest - show on the map with their names. The books they read are remembered, with how far they studied each. By default a server keeps none of it: it lasts as long as the visit, and a new visit starts with a map that shows the whole land and no names.

A game mode can keep it. It turns tracking on, the server records what each player’s game reports, and the mode saves that record in its own database and puts it back at the next login - quietly, with no “discovered” notice. It is the way a mode keeps a character’s stats and skills too, and it needs the mode’s database. The names are in the Discovery group of the server API. The examples are Lua; a C# mode has the same calls on IServerApi.

Each thing is a kind. The words are "places", "reading" and "ui", and the constants DISCOVERY_PLACES, DISCOVERY_READING and DISCOVERY_UI are the same (listed here):

Kind What it holds Kept
places the named places (villages, towns, camps), the points of interest and the kinds of them per level
reading the books they read and how far they studied each once for the whole game
ui the map pin they placed and the legend categories they switched off per level - no game reports it yet, see What to know

Once a kind is tracked, a player’s game reports what it holds of it a few seconds after each spawn and again when it changes. The server keeps that as the player’s record, a union that only grows: every report is added to what the record holds, and nothing a report leaves out is taken away, so a late report or a half-finished restore can never make it smaller. What you save is data the server keeps for you - for each kind a text in a fixed, versioned layout, ready for a database column. Never edit it. A record saved today is still readable after a server update.

SetDiscoveryTracking(kinds) names the kinds the players’ games report: true for every kind, one name, or a list of names. nil, false and {} track nothing. Call it when the mode starts. It answers false, and changes nothing, when a word is no kind.

function OnGameModeInit()
SetDiscoveryTracking({ DISCOVERY_PLACES, DISCOVERY_READING })
end

Nothing is tracked until the mode says so. With nothing tracked, no message is sent and no player’s game does any work. A reload of the mode starts tracking from nothing again - the new mode sets its own - but the players’ records stay. SetPlayerDiscoveryTracking(pid, kinds) gives one player a set of their own: {} in OnPlayerConnect keeps a guest’s game from reporting anything, nil hands them back to the server-wide set. GetDiscoveryTracking and GetPlayerDiscoveryTracking read them back.

GetPlayerDiscovery(pid) answers the player’s record:

local record = GetPlayerDiscovery(pid)
-- record.level "klaster" - the level the blocks are of
-- record.capable { "places", "reading" } - what this player's game can do (empty until its first report)
-- record.places { format = 1, data = "<base64 text>", bytes = 612, updated = 1761994000 }
-- record.reading { format = 1, data = "<base64 text>", bytes = 88, updated = 1761994120 }

A kind with nothing reported or set is absent, so test for it (if record.places then). data is the text to save, up to about 64,000 characters, which fits a TEXT column. format is the version of its layout (1 today): save it with the data and give it back as it was. bytes and updated (seconds since 1970) say how big the block is and when it last changed. A connected player always has a record, empty until their game has reported or the mode has set something, so read it again after the game has reported, not before.

When to save. OnPlayerDiscoveryChange(pid, kind, level) says a kind grew, or that the player’s game reported it for the first time, and the record already holds the new state. It runs at most once a kind every five seconds - a walk across the map is a few calls - and not for the mode’s own SetPlayerDiscovery. level is empty for the reading. Save on a timer too, and in OnPlayerDisconnect, where the record holds what the player found in their last seconds. A change still waiting for its five seconds is told just before the goodbye, and before a reloaded or stopped mode’s OnGameModeExit, so a mode that saves only what it was told misses nothing. Write only the blocks that differ from your last save.

Never write before the restore has gone out. Until then the record holds only what this game reports - nothing before its first report - and saving that over the stored character would lose it. So write only once the saved record was put back (section 4); from then on the record is the saved one united with the game’s, and what you write is never less than what was stored. A character with nothing saved yet has nothing to put back: write once IsPlayerDiscoveryKnown(pid, kind) says its game has reported, so the first save is the game’s own state.

The mode below keeps one row per character, level and kind, the books under an empty level. 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()
local KEPT = { DISCOVERY_PLACES, DISCOVERY_READING } -- the kinds this mode keeps
-- pid -> { who = the character, ready = may be written, fresh = nothing saved yet, dirty = grew since the last save,
-- stored = { ["kind|level"] = the text last written or read } }
local players = {}
function OnGameModeInit()
if not db then Log("no database: what players discover is not kept") return end
db:ExecuteSync("CREATE TABLE IF NOT EXISTS player_discovery (who VARCHAR(24) NOT NULL, level VARCHAR(32) NOT NULL, " ..
"kind VARCHAR(12) NOT NULL, format SMALLINT NOT NULL, data TEXT NOT NULL, PRIMARY KEY (who, level, kind))")
SetDiscoveryTracking(KEPT)
SetTimer(saveAllDiscovery, 60000, true) -- every minute, for whoever found something since
end
function OnPlayerConnect(pid)
players[pid] = { stored = {} }
restoreDiscovery(pid, GetPlayerName(pid):lower()) -- section 4; with passwords, once the password is right
end
function OnPlayerDiscoveryChange(pid, kind, level)
if players[pid] then players[pid].dirty = true end
end
function OnPlayerDisconnect(pid, reason)
saveDiscovery(pid) -- what they found in their last seconds is in the record
players[pid] = nil
end
function OnGameModeExit() -- the server stops, or the mode is reloaded
for pid in pairs(players) do saveDiscovery(pid) end
end

The other half writes. The minute’s round writes the characters marked dirty, and the goodbye writes whoever leaves. A block goes out only when its text differs from the one in stored, and a failed write puts everything back to be written again:

local function reported(pid) -- has their game reported any kind we keep?
for _, kind in ipairs(KEPT) do
if IsPlayerDiscoveryKnown(pid, kind) then return true end
end
end
function saveDiscovery(pid)
local p = players[pid]
if not (p and p.who) then return end
if p.fresh and reported(pid) then p.ready = true end -- a new character: its game's own first report is the start
local record = p.ready and GetPlayerDiscovery(pid) -- else nothing is written: the saved record is not back yet
if not record then return end
p.dirty = nil
local batch = {}
for _, kind in ipairs(KEPT) do
local block = record[kind]
local level = kind == DISCOVERY_READING and "" or record.level -- the books are the same on every level
local key = kind .. "|" .. level
if block and block.data ~= p.stored[key] then -- only what differs from the last save
p.stored[key] = block.data
batch[#batch + 1] = { "DELETE FROM player_discovery WHERE who = @w AND level = @l AND kind = @k",
{ w = p.who, l = level, k = kind } }
batch[#batch + 1] = { "INSERT INTO player_discovery (who, level, kind, format, data) VALUES (@w, @l, @k, @f, @d)",
{ w = p.who, l = level, k = kind, f = block.format, d = block.data } }
end
end
if #batch == 0 then return end
db:Batch(batch, function(ok, err)
if ok then return end
Log("player_discovery not saved: " .. tostring(err))
p.stored, p.dirty = {}, true -- the next round writes it all again
end)
end
function saveAllDiscovery()
for pid, p in pairs(players) do
if p.dirty then saveDiscovery(pid) end
end
end

SetPlayerDiscovery(pid, record) takes a record as GetPlayerDiscovery gives it - { level = "klaster", places = { format = 1, data = "..." }, reading = { ... } } - and the kinds it lacks are left alone. The server takes it at once (GetPlayerDiscovery right afterwards answers it) and sends it to the player’s game, which adds it to what it already holds. The game ends up with the saved discoveries and anything found since the player joined, and no notice of a discovery shows. A restore made before the player is in the world waits for their spawn, so the login may come first, and a game that reports before the restore lands is no trouble: the two are united.

It answers false for a player who is not there, or when a block was skipped - text that is no block of that kind, a format the server does not know, a block over the size limit. The other blocks are applied all the same. A skipped block is usually a saved row you cannot read, so the mode below writes nothing for that character this visit rather than replace it.

-- when the mode knows which character this is: OnPlayerConnect on a server without passwords, else once the password is right
function restoreDiscovery(pid, who)
local p = players[pid]
if not (db and p) then return end
p.who = who
local level = GetLevel():lower()
db:Query("SELECT kind, level, format, data FROM player_discovery WHERE who = @w AND (level = @l OR level = '')",
{ w = who, l = level }, function(rows, err)
if err then Log("player_discovery not read: " .. err) return end -- nothing is written this visit
if players[pid] ~= p then return end -- they left meanwhile; the id may be someone else's now
if #rows == 0 then
p.fresh = true -- nothing saved: their game's own first report starts the record
return
end
local record = { level = level }
for _, row in ipairs(rows) do
record[row.kind] = { format = row.format, data = row.data }
p.stored[row.kind .. "|" .. row.level] = row.data
end
if SetPlayerDiscovery(pid, record) then
p.ready, p.dirty = true, true -- the record is the saved one united with the game's: the next round writes what differs
else
Log("part of what " .. GetPlayerName(pid) .. " found could not be put back: nothing is saved this visit")
end
end)
end

The player’s game answers every restore a little later, and OnPlayerDiscoveryRestore(pid, kind, ok) tells the mode: ok is false when the game could not do that kind. The record kept what the mode set either way, so the callback is for a log line or a retry after the next spawn.

basicrp.lua in the server folder is the worked example. Its DISCOVERY table has a rule for each kind it can keep, places and reading (a book read once gives no experience again after a return), both off as shipped: no table is made, nobody’s game is asked for anything and the map is the game’s own. Turn a rule on and BasicRP makes its player_discovery table by itself, asks the players’ games for those kinds, saves what grew on the minute’s round and when the player leaves, and puts the rows back at the next login. That is this page’s pattern, guards included: a read that fails or a row the server cannot read saves nothing that visit. It does not keep the pin and the legend. freeroam.lua and duel_arena.lua keep nothing.

The table’s third rule, explorer, is about the game’s Explorer perk, which shows a character the whole map at once. With explorer = "strip" BasicRP leaves that perk out of the perks it puts back at the login (it stays among the character’s saved perks), so the map fills as the character finds places. "keep" is the default. The perk list has the perk’s ID.

  • One game, several characters. A mode that lets one game play several characters in turn calls ForgetPlayerDiscovery(pid, kinds) at each switch (every kind when left out) and then restores the next character’s record. Or it passes true as the third argument of SetPlayerDiscovery - replace - and the record becomes exactly the one given. The places cannot be forgotten yet: both skip them, do the other kinds and answer false, and a place that was found stays in the record and in the game. So such a mode keeps the reading and leaves places out of SetDiscoveryTracking. Otherwise the second character is saved with the first one’s places.
  • Places belong to a level, books to the character. A server runs one level (level in server.toml), and its record holds that level’s places and the books. A block of another level that the mode sets is kept in the record and never sent to the game. GetPlayerDiscovery(pid, level) reads another level’s record. The levels are listed here.
  • A player’s game may not be able to. capable in the record lists what their game can do, and is empty until its first report. A build of the game that KCD:MP does not know reports nothing and applies nothing. A game that closed while it read or put back a kind leaves that kind alone on that PC until KCD:MP or the game is updated. A restore it cannot do is answered false. The record keeps what you set, so saving it again changes nothing and nothing is lost.
  • The map pin and the legend are not reported yet. The server has a record and a saved format for ui, but no player’s game reports it in this version and capable never lists it, so a mode that tracks it gets nothing. They are the player’s own marks, not discoveries: when a game does report them, the newest report replaces the record instead of growing it.
  • Story quests. The game’s own story quests sleep unless the mode runs them (SetStoryQuests). With them on, a quest can undo a place a player found, so a mode that keeps places keeps them off. The server says so in its log when both are on.
  • The map. The record does not keep the fog of war. A server with map_fog = true under [ui] starts the fog again with every game and lifts it for a player with RevealPlayerMap. Without it, the default, the whole land shows from the start. What a restored record adds are the names of the places. RevealPlayerMap(pid, "places") also asks for the level’s places to be found, so the revealed map shows their names. No player’s game does that part in this version: the map is revealed without the names.