Skip to content

Make a web interface

A web interface is a set of pages - HTML, CSS and JavaScript - that every player’s game draws over the game while they are on your server: a menu, a shop window, a login screen, a map, a chat of your own. KCD:MP carries the browser engine that draws them (Chromium, through CEF) and starts it only on a server that has a web interface. A server without one looks exactly as it always did, and a player whose game cannot draw a page keeps the standard interface.

The interface is a folder of files next to the game mode. Each page in it is a frame: a layer of its own over the game, named in the folder’s ui.toml, which also says what the frame may do. The game mode shows and hides the frames, sends them data and hears what they send back. A page talks to the game through one object, KcdMp, that the game gives every page; the web interface SDK documents it.

This guide builds one frame from start to finish, then covers the keyboard and the mouse, replacing a piece of KCD:MP’s own interface, the look, big files and the players who have no web layer.

The interface lives in a folder called ui, next to the game mode’s client folder:

gamemodes/
mymode.lua the game mode
mymode/
client/ its client scripts, if it has any
ui/
ui.toml what the interface holds and what each frame may do
panel/
index.html
panel.css
panel.js

[client] ui in server.toml points somewhere else: another folder, or a .zip of one, which panel file managers handle better than the hundreds of files a build tool writes (server configuration).

At start the server checks the folder and prints what it found, with every mistake in ui.toml named by its key. An interface with errors is not sent, and players keep the standard interface. A player’s game downloads the interface when they join and keeps it, so a later join fetches only the files that changed.

api = 1
name = "My server's interface"
version = "1"
[frames.panel]
page = "panel/index.html"
focus = "input"
events_in = ["panel.*"]
events_out = ["panel.*"]
feeds = ["session"]

One [frames.<name>] section per frame. This one shows panel/index.html, may receive and send events whose names start with panel., reads the session feed (the server’s name, the player’s name, the ping) and may ask for the keyboard and the mouse cursor (below).

panel/index.html:

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="panel.css">
</head>
<body>
<div id="box">
<p id="hello">Welcome</p>
<button id="buy">Buy bread</button>
</div>
<script src="panel.js"></script>
</body>
</html>

panel/panel.js:

// the game mode's "panel.hello" event: the value is the table it sent, as JSON parsed
KcdMp.onServer("panel.hello", (value) => {
document.getElementById("hello").textContent = "Welcome, " + value.name;
});
// a click sends the game mode an event of the page's own
document.getElementById("buy").addEventListener("click", () => {
KcdMp.emitServer("panel.buy", { item: "bread" });
});

panel.css is any stylesheet. A page is transparent where it draws nothing, so the game shows through around the box.

-- the player's web layer is up (also called once right after OnPlayerConnect with the state their game reported)
function OnPlayerWebStatus(pid, status, reason)
if status == "ready" then
ShowPlayerWebFrame(pid, "panel")
end
end
-- the panel's page has loaded and listens
function OnPlayerWebReady(pid, frame)
if frame == "panel" then
SendClientEvent(pid, "panel.hello", { name = GetPlayerName(pid) })
end
end
-- what a page sends: the frame's name comes as the source
function OnClientEvent(pid, name, payload, source)
if source == "panel" and name == "panel.buy" then
local order = JsonDecode(payload)
if type(order) == "table" and order.item == "bread" then
-- check it as any other input from a player, then sell the bread
end
end
end

ShowPlayerWebFrame and the other calls answer true when the request was handed to the player’s game, or false and the reason in words: "no such player", "no web interface", "the player has no web layer", "no such frame". HidePlayerWebFrame hides a frame and keeps its page alive, so showing it again finds it as it was.

Key Default What it is
api - 1, the version of the web interface the folder is written for (required)
name, version "" the interface’s own name and version
[frames.<name>] - one frame; the name is lower-case letters, digits and - (root, ui, engine, kcdmp, theme, native, default, hidden, lab and lua are taken); at most 32 frames
page - the frame’s .html file, a path inside the folder (required)
preload false true loads the page at the join, hidden, so it shows at once when the mode asks
z 300 the frame’s layer, 0 to 999: a higher frame is drawn over a lower one
focus "input" what the frame may take when it asks: "never" (nothing), "input" (the cursor at once, the keyboard only just after the player’s own click or key in the frame), "open" (the cursor at any time, the keyboard after the player’s first click in the frame) - below
events_in [] the names of the mode’s events the page may receive; * at the end matches any rest: "panel.*"
events_out [] the names the page may send with emitServer
feeds [] the live values the page may read: "session" (the server, the level, the player, the ping) and "visibility" (whether the interface is drawn, and why not)
[components.<name>] - a piece of KCD:MP’s interface the folder draws or styles (below)
[theme] - the look of KCD:MP’s own web pieces (below)
[files] include = ["**"] include and exclude, lists of patterns, choose the files that are sent; every frame’s page and the theme’s stylesheet always are
[permissions] nothing what the pages may reach outside the folder: connect and img (hosts), embeds ("youtube"), capabilities ("webgl", "wasm", "audio", "workers")

A page can reach nothing outside its folder unless [permissions] names it: no other site, no other frame, nothing of the game but what KcdMp gives it. The files are the kinds a page uses: html, css, js, mjs, json, svg, txt, the images (png, jpg, jpeg, webp, avif, gif), the fonts (woff, woff2, ttf, otf) and the sounds and videos (ogg, opus, webm, wav).

From To How Arrives as
the game mode a page SendClientEvent / SendClientEventToAll KcdMp.onServer, in every page whose events_in takes the name (the client scripts get the same event)
the game mode one frame SendPlayerWebMessage KcdMp.onMessage, exactly the value sent
a page the game mode KcdMp.emitServer OnClientEvent, the frame’s name as its source
a client script a frame SendWebMessage KcdMp.onMessage
a page the client scripts KcdMp.post OnWebEvent, whose answer the page awaits

The values travel as JSON: a Lua table becomes an object, and JsonEncode and JsonDecode do it by hand on the server’s side. A message a frame cannot take yet waits until its page is ready. What comes back from a page is the player’s game’s word: a page can be changed on the player’s own PC, so check every request as you would a chat command.

A client script can drive frames too, with ShowWebFrame, HideWebFrame and SetWebFocus: a mode with client scripts keeps its screen logic there, a mode without any drives everything from the server.

A frame starts without either: the player keeps playing while it is drawn. It gets the cursor, the keyboard or both when the mode asks with SetPlayerWebFocus or the page asks with KcdMp.focus, and gives them back with SetPlayerWebFocus(pid, frame, false, false) or KcdMp.blur. The frame’s focus setting has the last word on the player’s side:

  • "never" - the frame gets no focus at all: an overlay the player only looks at.
  • "input" (the default) - the cursor at once; the keyboard only within five seconds of the player’s own click or key in the frame, so a page can take the keyboard from a click handler but never out of the blue.
  • "open" - the cursor at any time; the keyboard after the player’s first click in the frame since it was shown.

keepInput (the fifth argument of SetPlayerWebFocus) keeps the game’s walking keys working while no text field of the page has the focus: a menu the player can walk with. Esc gives the focus back unless the page handles the key itself, and asking for the keyboard more than six times a minute without any input from the player is refused. The keys a mode declares with RegisterKeyBinding reach the pages too, through KcdMp.onKeyBinding.

The chat, the scoreboard, the notices, the party frames, the voice indicator, the emote wheel, the HUD texts, the sleep fade, the death screen, the shop and the dialogs each have two more drawers than native and hidden (Change the interface):

  • default - KCD:MP’s own web version of the piece, which your theme styles: a chat with channel tabs and command suggestions as the player types, a scoreboard with the mode’s own columns (SetScoreboardColumns).
  • the name of one of your frames - your page draws the piece. It receives the piece’s lines, roster or notices, its keys and its settings, and calls the piece’s actions: the piece’s contract (chat@1, scoreboard@1, notices@1 …) is documented under Drawing a component. A frame draws one piece at a time.

[components.<name>] in ui.toml sets what every player starts with:

[components.chat]
drawer = "default"
[components.scoreboard]
drawer = "board" # the frame [frames.board] draws it
contract = "scoreboard@1"
mode = "toggle" # the piece's options, as SetInterfaceOptionsForAll takes them

and the mode changes it for one player or everyone with SetPlayerInterface and SetInterfaceForAll. While a player’s game cannot draw the web drawer - no web layer, a page not ready yet, a page that stopped - the native piece draws instead, and the web one takes over the moment it can; GetPlayerInterface says which draws now.

The loading screen has a web version too, but only ui.toml chooses it: [components.loading] drawer is "default" or a frame’s name, and without the section a frame called loading draws it. It shows while the level loads, before anything the mode sends can arrive, so its data comes from SetPlayerLoadingHandover, and manual_shutdown = true keeps it up until the mode calls ClosePlayerLoadingScreen.

The default pieces take a theme from ui.toml:

[theme]
css = "theme.css" # a stylesheet of the folder, applied to KCD:MP's web pieces
[theme.vars]
"--kcdmp-accent" = "#F6E890"
"--kcdmp-text" = "#F3EEDF"
"--kcdmp-backing" = "rgba(20, 16, 12, 0.6)"
[theme.chat.templates] # chat lines laid out by the mode's templates
me = "<i>* {0} {1}</i>"

The variables are --kcdmp- names: accent, text, text-dim, backing, font, radius and the pieces’ own. A chat line sent with SendChatMessage and a template name fills the template’s {0} to {15} with its values, always as text. The game’s own typefaces are there for every page: "KcdMp Regular", "KcdMp Display" and "KcdMp Manuscript" in a font-family.

A player downloads the interface over the game’s connection when they first join, so keep it small: [client] ui_max_mb caps it (8 MB by default, 64 at most) and the server log warns from 4 MB up. Pictures, fonts, sounds and videos over 1 MB come from a host of your own instead: set [client] ui_cdn to its address, run the server once with --pack-ui <folder> to write the files to upload there, and list the host in [permissions] connect. Without ui_cdn a file that big is an error.

OnPlayerWebStatus says how a player’s web layer stands: "starting", "ready", "failed" (with the reason) or "off" - an older game, or a player who turned Server interfaces off in the launcher. Those players keep the standard interface, and IsPlayerWebReady answers false for them. A mode whose screens matter has a second way for them: RegisterUiFallback sends a frame’s events to a client script’s box instead, player by player.

A reload of the mode leaves the frames on the players’ screens, and the new mode hears OnPlayerWebStatus and OnPlayerWebReady again.

  • Any build tool works - the folder only needs the files it writes. Vite needs base: './'. Source maps, node_modules and the sources stay out with [files] exclude.
  • In a browser. A page opened outside the game runs in mock mode: copy web/sdk.js from your KCD:MP folder next to your pages and load it first with <script src="sdk.js"></script> (in the game the copy steps aside for the game’s own). Then KcdMp.mock plays the game’s part: events, messages, feeds and keys.
  • In the game. Turn on UI developer mode in the launcher’s settings: the F9 window gets a Web tab with every frame’s state, the pages’ console, a line to run JavaScript in a frame and a reload button, and the game argument -KcdMp_web_dev_url <frame>=http://127.0.0.1:<port>/ (the launcher’s Game arguments) loads a frame from a development server on your own PC. In this mode the launcher joins only servers on your own list (a favourite, a line of your server list, this PC).
  • Script errors stay in the player’s own log unless [client] ui_report_errors = true: then the mode hears each one in OnPlayerWebError.
  • The types of the whole KcdMp object are in KcdMp-web.d.ts, for TypeScript and for editors that read it.

A C# mode has every call above on IServerApi and every callback on IGameMode, with the same names and the same rules: ShowPlayerWebFrame(player, "panel", out var reason), OnPlayerWebStatus(IPlayer, string status, string reason) and the rest - the C# page lists them under Web interface.