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.
Running a mode
Section titled “Running a mode”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 freeroamwatch = false # true: the file is re-read when it changes on disk (a development server)KcdMp.Server --gamemode gamemodes/my_mode.luaThe 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.
The script
Section titled “The script”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 trueend
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"endScripts 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).
A command with arguments
Section titled “A command with arguments”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 falseend/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].
The tick
Section titled “The tick”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
OnTickwould; - 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); anOnTickthat does real work every tick is the first place to look when players stutter. Do the heavy things on a timer every second instead.
Ids and units
Section titled “Ids and units”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.
Items, saved characters and the audit
Section titled “Items, saved characters and the audit”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,SetHorseItemsand a shop’s deals are what the server knows about, and they count at once: aGetPlayerInventoryright 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 underenforceit is taken out again. - Restoring a saved character at the login:
SetPlayerInventory(pid, saved.items)(itswornentries are put on),GivePlayerMoney(pid, saved.money - GetPlayerMoney(pid))andSetPlayerProgress. 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),OnHorseItemsChangefor saddlebags, andOnPlayerDisconnect. 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:
OnPlayerNativeItemfor what the game makes itself - a blacksmith’s swords, which are refused until the mode adopts them -,OnPlayerAuditViolationfor items an event handed out, andOnPlayerProgressViolationfor 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] modetoenforce. What the mode reads of a bag underobserveis what the player’s game holds; underenforceit 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, unlessOnPlayerCheatDetectedreturnsfalse; every other sign of cheating reaches that callback as a hint, and what it means is the mode’s to decide.
When a script fails
Section titled “When a script fails”- 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;printis the same function.
Your editor
Section titled “Your editor”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.
Where next
Section titled “Where next”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.
