Skip to content

Getting started

A KCD:MP server runs one game mode: a Lua script that defines the rules of that server - where players spawn, what the chat and the commands do, who may hurt whom, what stands in the world. The server calls the script’s callbacks when something happens (OnPlayerConnect, OnPlayerDeath, OnTick …) and the script calls the server back through the functions on the other pages (SendClientMessage, SetPlayerPos, CreateHorse …). A mode that defines nothing is a valid mode: the server spawns everyone at its default point and answers its own chat commands.

The script is named in server.toml or on the command line; the command line wins:

[gamemode]
script = "gamemodes/freeroam.lua" # relative to the server's working directory; "" = the built-in freeroam
watch = false # true: the file is re-read when it changes on disk (a development server)
Terminal window
KcdMp.Server --gamemode gamemodes/my_mode.lua

The server ships with its settings (server.toml.example, copied to server.toml at the first start) and the example modes beside it (Setting up has the folder and the first run); your own mode is a file next to them, named in your server.toml. require resolves relative to the script’s directory, so a mode may be a folder of files. Three modes ship with the server and are the best examples: gamemodes/freeroam.lua (spawns, greetings, a HUD clock, a greeting zone, the party commands, the NPC actor demo), gamemodes/duel_arena.lua (a queue, two players at a time in an arena, the rest untouchable) and gamemodes/marker/marker.lua (a checkpoint run with a client script that draws the marker in each player’s game).

A mode is reloaded without a restart by /reload (admins), by ReloadGameMode() from the script itself or by [gamemode] watch = true.

Plain Lua 5.4 with the standard libraries. The server registers the API as globals before the script runs; the script defines the callbacks it cares about as globals too:

SetGameModeText("hello")
local spawnX, spawnY, spawnZ, spawnYaw = GetDefaultSpawn()
function OnPlayerConnect(pid)
SendClientMessageToAll(COLOR_SERVER, GetPlayerName(pid) .. " joined")
end
function OnPlayerRequestSpawn(pid)
SetSpawnInfo(pid, spawnX + 1.5 * (pid % 8), spawnY, spawnZ, spawnYaw) -- a step per player, so nobody spawns inside anyone
return true
end
function OnPlayerCommandText(pid, cmd, args)
if cmd == "hello" then
SendClientMessage(pid, COLOR_SERVER, "Hello, " .. GetPlayerName(pid))
return true -- handled
end
return false -- the server's built-in commands, then "Unknown command"
end

Scripts are trusted - the server admin wrote them - so the luanet table NLua adds is there too (luanet.import_type("System.IO.File") gives a script the .NET class library: files, a database, HTTP).

Every / line a player types reaches OnPlayerCommandText as the command word and the rest of the line. sscanf turns that rest into typed values - one letter per argument - and tells you what is wrong when it cannot, so a command is a handful of lines:

function OnPlayerCommandText(pid, cmd, args)
if cmd == "pay" then
local target, amount = sscanf(args, "ud") -- u = a player, d = a whole number
if target == false then
SendClientMessage(pid, COLOR_RED, "usage: /pay <player> <amount> - " .. amount) -- amount holds the reason
return true
end
if amount <= 0 then
SendClientMessage(pid, COLOR_RED, "the amount must be positive")
return true
end
SendClientMessage(target, COLOR_GREEN, GetPlayerName(pid) .. " paid you " .. amount)
SendClientMessage(pid, COLOR_SERVER, "you paid " .. GetPlayerName(target) .. " " .. amount)
return true
end
return false
end

/pay hans 50 gives Hans’s pid and 50 - hans, Hans, ha or his pid all name him, as long as only one player matches (GetPlayerId does the matching). /pay hans abc gives false and "argument 2 (abc) is not a whole number", /pay nobody 50 gives "no player called nobody", /pay hans gives "argument 2 is missing". The letters: u a player, d a whole number, f a number, s one word (or a "quoted string"), z the rest of the line; a ? after a letter makes it optional. So /whisper <player> <message> is "uz", /kick <player> [reason] is "uz?", /setspawn <x> <y> <z> [yaw] is "fffd?". The shipped modes do it this way: freeroam.lua’s /hurt [player] [amount] and duel_arena.lua’s /arena x y z [yaw].

Everything a mode does happens on the server’s simulation thread, one tick at a time, 30 times a second ([rates] tick_hz). The callbacks run from inside a tick; the calls a callback makes take effect in that tick’s output. Two consequences:

  • a script never races with anything - no locks, no threads, a timer’s function runs where OnTick would;
  • a slow callback delays the whole tick for every player. The server log says so while it happens (the slowest tick took 41 ms against a 33 ms budget); an OnTick that does real work every tick is the first place to look when players stutter. Do the heavy things on a timer every second instead.
pid a player: 0 to GetMaxPlayers() - 1, given at the handshake and reused after a disconnect. Keep the pid, not the name, while a player is on; check IsPlayerConnected(pid) before trusting a pid kept across ticks
id a world entity - a horse, a pickup, a prop, an NPC actor, a dog: the protocol’s net id, unique while it lives, nil from every getter once it is gone
positions metres in the level’s world space, as the server’s /pos command reports them (GetPlayerPos gives the same numbers). z < 0 when spawning or teleporting means “on the terrain”
yaw degrees, 0 = facing +Y, counter-clockwise (the game’s convention)
colors 0xRRGGBBAA - 0xFF0000FF is opaque red; the constants name the usual ones
time GetServerTime() in milliseconds; timers in milliseconds; the world clock in hours (13.5 = 13:30)
strings UTF-8; chat lines and names as the players typed them, Cyrillic and accented letters included. string.lower / string.upper (and s:lower()) change the letters of every alphabet, so ("ИВАН"):lower() is "иван"; #s counts bytes, utf8.len(s) counts letters (a Cyrillic letter is two bytes)
keys of game things items, souls, buffs and meshes are named by id, name or GUID from the reference lists; a call given an unknown key fails quietly (nil, false) and logs why

A function that reads a player who is not connected returns nil (positions, names) or a neutral value (-1 for a ping); one that acts on them returns false. Nothing raises an error for a bad id.

Every item a player carries is a record of the server’s, and the server checks what the player’s game reports against those records - the item check and the progression check of [audit]. A mode that hands out, keeps and restores items works with that, and the rules are few:

  • Give and take through the API. GivePlayerItem, GivePlayerMoney, SetPlayerInventory, CreatePickup, SetContainerItems, SetHorseItems and a shop’s deals are what the server knows about, and they count at once: a GetPlayerInventory right after a give shows the item, while the player’s game makes it a moment later. An item that gets into a player’s game any other way - a client script’s, an event of the game’s own - is one nothing explains, and under enforce it is taken out again.
  • Restoring a saved character at the login: SetPlayerInventory(pid, saved.items) (its worn entries are put on), GivePlayerMoney(pid, saved.money - GetPlayerMoney(pid)) and SetPlayerProgress. All three count the moment they are made, so the mode may read the character back and save it without waiting for the player’s game to catch up - and what the mode itself restores is never an excess. A saved item the game no longer has (an item a game update took away) is dropped from the records after half an hour; the rest of the restore stands.
  • Saving on OnPlayerInventoryChange (the records changed, at most once a second), OnHorseItemsChange for saddlebags, and OnPlayerDisconnect. A player’s bag is emptied at their first spawn of a visit and when a spawn’s outfit presets change, and the purse the server holds for them stays; a respawn with the same presets keeps what they carry.
  • Vouching is how a mode tells the server about what it hands out in a way the server cannot see: OnPlayerNativeItem for what the game makes itself - a blacksmith’s swords, which are refused until the mode adopts them -, OnPlayerAuditViolation for items an event handed out, and OnPlayerProgressViolation for XP a teacher of the mode’s gives.
  • Start in observe, the default: the server only logs what it would remove or put back (would remove, would set back). Read the log through a session of your mode - every line that names something the mode did itself is a give to make through the API or a vouch to add - then switch [audit] mode to enforce. What the mode reads of a bag under observe is what the player’s game holds; under enforce it is what the server’s records explain.
  • A cheat the server can prove - a game that ignores an order, forges a claim or goes silent - gets the [audit] action (a kick by default) in either mode, unless OnPlayerCheatDetected returns false; every other sign of cheating reaches that callback as a hint, and what it means is the mode’s to decide.
  • A runtime error inside a callback is logged ([lua] error in OnPlayerText: ..., the first fifty in full, then counted) and that callback’s effect is skipped - the server keeps running, the player keeps playing.
  • A syntax error or an error while the script loads stops the server at startup with the message, so a broken mode is found before players join. On a reload the same error leaves the built-in freeroam in charge until the next reload (game mode: x.lua failed to load (...); the built-in freeroam runs until the next reload).
  • Log(...) writes to the server log with a [lua] prefix; print is the same function.

The server folder’s sdk/KcdMp.d.lua describes this whole API - every function, callback and constant, with its documentation and types - for the Lua language server: completion as you type, the page’s text on hover, and a warning for a name that does not exist or an argument of the wrong type, before the server ever runs the script. In VS Code install the “Lua” extension (sumneko / LuaLS) and open the gamemodes folder - it comes with a .luarc.json that points the language server at the file. For a mode in a folder of its own, put a .luarc.json next to the script:

{ "runtime.version": "Lua 5.4", "workspace.library": ["../../sdk"] }

or copy KcdMp.d.lua into the folder. A coding assistant working in that folder reads the same file, so it knows the API instead of guessing at it (AI assistants has a CLAUDE.md to start from); sdk/KcdMp-client.d.lua is the same for a client script. Other editors and the C# side: Setting up.

The Server API index lists every callback and function by topic, each with its own page; the Combat guide explains the model behind the combat callbacks; Constants lists every constant. The keys that name game things - items, souls, buffs, meshes, presets, clips, doors - are in the reference.