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)- The server's
uifolder holds the pages - plain files or the output ofnpm run build. - 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.
- A client script opens a page with
createBrowserand gets aBrowserobject. - The page and the script exchange events:
gmp.trigger(name, ...)in the page calls the script'sbrowser:on(name, fn);browser:trigger(name, ...)in the script calls the page'sgmp.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). - What the server must know goes on with the usual custom events:
triggerServerEventandtriggerClientEvent.
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:
<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 addressgmp://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.
<!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>-- 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 gameshowCursoris 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,submitevents, form controls,:hover/:activestyles,<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.
<!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>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)-- 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.
| Tool | Setting for relative paths | Build output |
|---|---|---|
| Vite (React, Vue, Svelte, Solid, vanilla) | base: "./" in vite.config | dist/ |
| Angular | ng build --base-href ./ | dist/<project>/browser/ |
| Create React App | "homepage": "." in package.json | build/ |
| Vue CLI | publicPath: "./" in vue.config.js | dist/ |
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:
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 installimport { 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:
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]);
}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-tsSet base: "./" in vite.config.ts as above. A composable plays the role of the React hook:
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));
}<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 devin a normal browser: thegmpstand-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=9222before starting the game, then openchrome://inspectin Chrome, addlocalhost:9222and 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,confirmandpromptshow 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:
isBrowserSupportedtells it,createBrowserreturnsnil.
