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 server.toml 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(COLOUR_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, COLOUR_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, COLOUR_RED, "usage: /pay <player> <amount> - " .. amount) -- amount holds the reason
return true
end
if amount <= 0 then
SendClientMessage(pid, COLOUR_RED, "the amount must be positive")
return true
end
SendClientMessage(target, COLOUR_GREEN, GetPlayerName(pid) .. " paid you " .. amount)
SendClientMessage(pid, COLOUR_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 game’s own console reports them (GetPlayerPos ↔ the game’s pos). z < 0 when spawning or teleporting means “on the terrain”
yaw degrees, 0 = facing +Y, counter-clockwise (the game’s convention)
colours 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
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.

  • 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.