Перейти к содержимому
Gothic Multiplayer
Gothic MP Wiki

Web UI guide

Your own in-game UI in HTML, CSS and JavaScript - plain or built with React, Vue, Angular or Svelte - from the server's ui folder to a login panel that talks to Lua and the server.

A server can draw its own UI over the game with web technologies: a login panel, a shop, a stats bar, a scoreboard, a whole menu system. The pages are ordinary web pages - HTML, CSS and JavaScript - written by hand or built with any framework (React, Vue, Angular, Svelte...). The game shows them with Chromium (CEF - the Chromium Embedded Framework), the engine of Google Chrome, so everything a modern browser can do works: flexbox and grid, CSS animations, web fonts, SVG, canvas, ES modules.

This guide goes from an empty folder to a login panel built with React whose result goes to the server.

How it works

server folder                     player's game
 ├ config.xml   <ui folder="ui"/>  ──join──▶  downloads the changed files (cached per server)
 ├ ui/login/index.html, assets/...             │
 └ scripts/client.lua (type="client")          ▼
                                     createBrowser("login/index.html", ...)
                                       page (JavaScript)  ◀── window.gmp ──▶  Browser (Lua)  ◀── events ──▶  server (Lua)
  1. The server's ui folder holds the pages - plain files or the output of npm run build.
  2. Joining the server, every player downloads the folder; only new and changed files are downloaded, the rest is kept per server on the player's computer.
  3. A client script opens a page with createBrowser and gets a Browser object.
  4. The page and the script exchange events: gmp.trigger(name, ...) in the page calls the script's browser:on(name, fn); browser:trigger(name, ...) in the script calls the page's gmp.on(name, fn). Data travels as JSON - objects and arrays in JavaScript are tables in Lua. The JavaScript side is described in JavaScript (window.gmp).
  5. What the server must know goes on with the usual custom events: triggerServerEvent and triggerClientEvent.

Coordinates

Positions and sizes are CSS pixels of a 1920x1080 screen. A page looks the same at every resolution: on a 4K screen it is rendered at scale 2 (twice as sharp), at 1440p at scale 1.33. On wider screens the width grows (2560x1080 on 21:9) - getScreenSize gives the whole screen.

The ui folder of the server

Add the <ui> element to config.xml and create the folder next to it:

config.xml
<scripts>
  <script src="scripts/main.lua" type="server" />
  <script src="scripts/client.lua" type="client" />
</scripts>
<ui folder="ui" />
my-server/
├ config.xml
├ scripts/
│ ├ main.lua
│ └ client.lua
└ ui/
  ├ hud/
  │ └ index.html
  └ login/              <- e.g. the dist/ folder of a React build
    ├ index.html
    └ assets/
      ├ index-4f1c2a.js
      └ index-9b3e77.css
  • Everything in the folder (with subfolders) is sent to the players. The server reads it once at start - restart the server after changing the files; players get the changes the next time they join.
  • In scripts a page is its path in the folder: createBrowser("login/index.html", ...). Inside the page relative paths work as on any website (./assets/app.js, ../shared/logo.png); in the browser the folder is the address gmp://ui/.
  • Limits: 100 MB together, 20 MB per file, 5000 files; names must be valid Windows file names.
  • While the files download, the chat shows the progress; client scripts start when all files are there.

No internet in pages

Pages can load only the server's own files (and data: / blob: URLs). Requests to other addresses - fetch, images, fonts or scripts from a CDN, Google Fonts, WebSockets - are blocked. Put every library, font and image into the build. The player's IP never reaches anyone but the game server, and the UI is exactly what the server ships.

Your first page: a stats bar

A bar at the bottom of the screen, always visible, with the player's health, mana and gold. It is a page over the whole screen with a transparent background - only the bar is painted.

ui/hud/index.html
<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <style>
    html, body { margin: 0; height: 100%; background: transparent; font: 600 16px/1.4 "Segoe UI", sans-serif; color: #f2e6cf; }
    .bar { position: absolute; left: 50%; bottom: 24px; transform: translateX(-50%); display: flex; gap: 24px;
           padding: 8px 18px; background: rgba(14, 12, 9, 0.6); border: 1px solid rgba(227, 196, 137, 0.4); }
    .health { color: #ff8a80; } .mana { color: #8ab4ff; } .gold { color: #e3c489; }
  </style>
</head>
<body>
  <div class="bar">
    <span class="health">HP <b id="health">-</b></span>
    <span class="mana">Mana <b id="mana">-</b></span>
    <span class="gold">Gold <b id="gold">0</b></span>
  </div>
  <script>
    gmp.on("stats", (s) => {
      document.getElementById("health").textContent = `${s.health} / ${s.maxHealth}`;
      document.getElementById("mana").textContent = `${s.mana} / ${s.maxMana}`;
    });
    gmp.on("gold", (amount) => { document.getElementById("gold").textContent = amount; });
    gmp.trigger("ready");   // tell the script the gmp.on handlers are registered
  </script>
</body>
</html>
scripts/client.lua
-- client scripts start when the player is already in the world - the page can open right away
local w, h = getScreenSize()
local hud = createBrowser("hud/index.html", 0, 0, w, h)   -- nil: this game cannot show web pages (isBrowserSupported)

local function update()
  local me = getLocalPlayer()
  if not hud or not me then return end
  hud:trigger("stats", {
    health = me:getHealth(), maxHealth = me:getMaxHealth(),
    mana = me:getMana(), maxMana = me:getMaxMana(),
  })
end

if hud then
  hud:on("ready", function()
    update()
    setTimer(update, 500, true)   -- twice a second
  end)
end

-- the server keeps the gold: triggerClientEvent(pid, "gold", 35)
addEventHandler("gold", function(amount)
  if hud then hud:trigger("gold", amount) end
end)

Why the ready event? createBrowser returns at once, while the page is still loading. An event sent before the page registered its gmp.on handlers is lost. So the page says when it is ready (gmp.trigger("ready")), and the script sends its data from browser:on("ready"). The same pattern works for every framework - in React it is a useEffect, in Vue onMounted.

Clicks and the keyboard

Without the cursor a page only shows things - the mouse and the keyboard control the game. To let the player click and type:

showCursor(true)    -- the game's cursor; the game ignores the mouse and the keys meanwhile
page:focus()        -- optional: the keys go to this page at once (an <input autofocus> can be typed into)
-- ...
showCursor(false)   -- the control goes back to the game
  • showCursor is the game's cursor - it works for every page at once.
  • A click goes to the page under the cursor. Fully transparent pixels of a page let the click through to the page below (or to nothing) - a full-screen transparent page does not block the others.
  • The keys go to the page the player clicked last (or the one given with browser:focus()).
  • In the page it is ordinary web code: click, input, keydown, submit events, form controls, :hover / :active styles, <select> lists, text selection, copy and paste.
  • While the player types into a page, the game's keys (also T for the chat) do not react.

A common pattern - the page closes itself with a button or Esc and the script gives the control back:

<button id="close">Close</button>
<script>
  const close = () => gmp.trigger("close");
  document.getElementById("close").addEventListener("click", close);
  document.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); });
</script>
shop:on("close", function()
  shop:setVisible(false)   -- or shop:destroy()
  showCursor(false)
end)

A login panel (plain HTML)

The whole flow: the panel opens when the player enters the world, the data goes to the server, the server answers, the panel shows the error or closes.

ui/login/index.html
<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <style>
    html, body { margin: 0; height: 100%; background: rgba(0, 0, 0, 0.55); font: 16px/1.4 "Segoe UI", sans-serif; color: #f2e6cf; }
    form { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 360px;
           padding: 28px; background: #16130f; border: 1px solid rgba(227, 196, 137, 0.5); }
    h1 { margin: 0 0 18px; font-size: 24px; color: #e3c489; }
    input, button { width: 100%; box-sizing: border-box; margin-top: 10px; padding: 10px; font: inherit; }
    input { background: #0d0b08; color: inherit; border: 1px solid #5c5240; }
    button { background: #e3c489; color: #16130f; border: 0; cursor: pointer; font-weight: 600; }
    button:disabled { opacity: 0.5; }
    .error { min-height: 22px; margin-top: 10px; color: #ff8a80; }
  </style>
</head>
<body>
  <form id="form">
    <h1>Lorem ipsum</h1>
    <input id="user" placeholder="Name" autocomplete="off" autofocus>
    <input id="password" type="password" placeholder="Password">
    <div class="error" id="error"></div>
    <button id="submit">Log in</button>
  </form>
  <script>
    const $ = (id) => document.getElementById(id);
    $("form").addEventListener("submit", (e) => {
      e.preventDefault();
      $("submit").disabled = true;
      $("error").textContent = "";
      gmp.trigger("login", { user: $("user").value, password: $("password").value });
    });
    gmp.on("loginResult", (ok, message) => {
      $("submit").disabled = false;
      if (!ok) $("error").textContent = message;
    });
    gmp.on("setUser", (name) => { $("user").value = name; $("password").focus(); });
    gmp.trigger("ready");
  </script>
</body>
</html>
scripts/client.lua
local w, h = getScreenSize()
local login = createBrowser("login/index.html", 0, 0, w, h)

if login then
  login:setOrder(200)   -- above other pages
  showCursor(true)
  login:focus()
  login:on("ready", function() login:trigger("setUser", getLocalPlayer():getName()) end)
  login:on("login", function(data)
    triggerServerEvent("login", data.user, data.password)
  end)
else
  addChatMessage(0xFF8080, "Your game cannot show the login panel - log in with /login <name> <password>.")
end

addEventHandler("loginResult", function(ok, message)
  if not login then return end
  login:trigger("loginResult", ok, message)
  if ok then
    login:destroy()
    login = nil
    showCursor(false)
  end
end)
scripts/main.lua (server)
-- example only: a real server keeps accounts in a database (PostgreSQL guide) with hashed passwords
local accounts = { lorem = "ipsum" }

addEventHandler("login", function(pid, user, password)
  if type(user) ~= "string" or type(password) ~= "string" then return end   -- never trust data from a client
  if accounts[user] == password then
    triggerClientEvent(pid, "loginResult", true, "Welcome, " .. user)
    -- remember that pid is logged in, load their data...
  else
    triggerClientEvent(pid, "loginResult", false, "Wrong name or password.")
  end
end)

The server decides

A page and a client script run in the player's game - the player can change both. Check everything on the server: who is logged in, what they may buy, how much gold they have. The page only shows things and asks; the server answers.

Accounts belong in a database - see the PostgreSQL guide.

React, Vue, Angular, Svelte...

Any framework works as long as the result of its build is static files with relative paths. Build the project, copy the build output into a folder of the server's ui folder and open its index.html.

ToolSetting for relative pathsBuild output
Vite (React, Vue, Svelte, Solid, vanilla)base: "./" in vite.configdist/
Angularng build --base-href ./dist/<project>/browser/
Create React App"homepage": "." in package.jsonbuild/
Vue CLIpublicPath: "./" in vue.config.jsdist/

Routers: use hash routing (createHashRouter in React Router, createWebHashHistory in Vue Router) - the page address is a file, not a web server that answers every path.

The bridge in a framework

window.gmp exists in every page in the game. During development the page runs in a normal browser (npm run dev), where there is no game - a small module gives the page a stand-in and types:

src/gmp.ts
type Handler = (...args: any[]) => void;

interface Gmp {
  trigger(name: string, ...args: unknown[]): void;
  on(name: string, handler: Handler): void;
  off(name: string, handler: Handler): void;
}

declare global {
  interface Window { gmp?: Gmp }
}

// outside the game (npm run dev in a browser) a stand-in prints the events to the console
export const gmp: Gmp = window.gmp ?? {
  trigger: (name, ...args) => console.log("gmp.trigger", name, ...args),
  on: () => {},
  off: () => {},
};

React (Vite)

npm create vite@latest login -- --template react-ts
cd login
npm install
vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  base: "./",          // relative paths - the page is opened from the server's ui folder
  plugins: [react()],
});

A hook connects a component to a game event and removes the handler when the component goes away:

src/useGmpEvent.ts
import { useEffect, useRef } from "react";
import { gmp } from "./gmp";

export function useGmpEvent(name: string, handler: (...args: any[]) => void) {
  const latest = useRef(handler);
  latest.current = handler;
  useEffect(() => {
    const listener = (...args: any[]) => latest.current(...args);
    gmp.on(name, listener);
    return () => gmp.off(name, listener);
  }, [name]);
}
src/App.tsx
import { useEffect, useState } from "react";
import { gmp } from "./gmp";
import { useGmpEvent } from "./useGmpEvent";

export default function App() {
  const [user, setUser] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");
  const [busy, setBusy] = useState(false);

  useGmpEvent("setUser", (name: string) => setUser(name));
  useGmpEvent("loginResult", (ok: boolean, message: string) => {
    setBusy(false);
    if (!ok) setError(message);
  });

  // the handlers above are registered in this render - now the script may send data
  useEffect(() => gmp.trigger("ready"), []);

  const submit = (e: React.FormEvent) => {
    e.preventDefault();
    setBusy(true);
    setError("");
    gmp.trigger("login", { user, password });   // -> login:on("login", function(data) ... end) in Lua
  };

  return (
    <form className="login" onSubmit={submit}>
      <h1>Lorem ipsum</h1>
      <input value={user} onChange={(e) => setUser(e.target.value)} placeholder="Name" autoFocus />
      <input value={password} onChange={(e) => setPassword(e.target.value)} type="password" placeholder="Password" />
      <div className="error">{error}</div>
      <button disabled={busy}>Log in</button>
    </form>
  );
}
npm run build      # -> dist/index.html, dist/assets/...

Copy the contents of dist/ to ui/login/ of the server and restart it. The Lua scripts of the plain-HTML panel above work without a change - the page sends the same ready and login events and listens to the same setUser and loginResult ones. That is the whole contract between the page and the game: event names and their arguments.

For bigger UIs keep the game's state in one place of the app - a context or a store (Zustand, Redux, Pinia) filled by gmp.on handlers - and let the components read it. Many events at a high rate (a timer every 100 ms) are fine; one event with a whole table is better than many small ones.

Vue (Vite)

npm create vite@latest login -- --template vue-ts

Set base: "./" in vite.config.ts as above. A composable plays the role of the React hook:

src/useGmpEvent.ts
import { onMounted, onBeforeUnmount } from "vue";
import { gmp } from "./gmp";

export function useGmpEvent(name: string, handler: (...args: any[]) => void) {
  onMounted(() => gmp.on(name, handler));
  onBeforeUnmount(() => gmp.off(name, handler));
}
src/App.vue
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { gmp } from "./gmp";
import { useGmpEvent } from "./useGmpEvent";

const user = ref("");
const password = ref("");
const error = ref("");
const busy = ref(false);

useGmpEvent("setUser", (name: string) => (user.value = name));
useGmpEvent("loginResult", (ok: boolean, message: string) => {
  busy.value = false;
  if (!ok) error.value = message;
});
onMounted(() => gmp.trigger("ready"));

function submit() {
  busy.value = true;
  error.value = "";
  gmp.trigger("login", { user: user.value, password: password.value });
}
</script>

<template>
  <form class="login" @submit.prevent="submit">
    <h1>Lorem ipsum</h1>
    <input v-model="user" placeholder="Name" autofocus />
    <input v-model="password" type="password" placeholder="Password" />
    <div class="error">{{ error }}</div>
    <button :disabled="busy">Log in</button>
  </form>
</template>

Build with npm run build and copy dist/ to the server's ui/ folder, like the React version.

Angular, Svelte and others

The same three things: relative paths in the build, the gmp module (or window.gmp directly), and handlers registered before the page sends ready. In Angular register them in a service and send ready from ngOnInit of the root component; in Svelte use onMount / onDestroy.

Developing and debugging

  • In a browser first. Run the app with npm run dev in a normal browser: the gmp stand-in prints what the page sends; call the page's handlers from the browser console to try the screens out. Set the browser window to 1920x1080 to see the layout as in the game.
  • Console. In the game, console.log, errors and warnings of pages go to the player's GROnline log (client_debug.log, lines [WEB]), with the file and line.
  • DevTools. Set the environment variable GMP_CEF_DEVTOOLS=9222 before starting the game, then open chrome://inspect in Chrome, add localhost:9222 and inspect the game's pages with the full Chrome DevTools (elements, console, network, performance).
  • New files reach the game after a server restart and a new join; only the changed files are downloaded.
  • alert, confirm and prompt show nothing in the game (the text goes to the log) - draw your own dialogs.

Good to know

  • Pages are drawn on the CPU and copied into the game's picture. A transparent full-screen page costs nothing where it paints nothing; large areas that change every frame (full-screen animations, video) cost the most. Hide pages you do not need (browser:setVisible(false) - a hidden page does not render at all) instead of keeping them on screen fully transparent.
  • A page keeps its state while hidden; browser:destroy() throws it away. All pages of the scripts are closed when the player leaves the server.
  • At most 32 pages per player at a time; one page can hold many screens (a single-page app).
  • Pages cannot open new windows or leave the server's files (links to websites do nothing).
  • Players without the Chromium component (it is downloaded once by the launcher) have no pages: isBrowserSupported tells it, createBrowser returns nil.